Compare commits
47 Commits
preview-ne
...
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 | ||
|
|
525a538f34 | ||
|
|
0d2273ff6e | ||
|
|
e035cd84ae | ||
|
|
438ccfe9c3 |
@@ -7,7 +7,7 @@
|
|||||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
"projectName": "jellyseerr",
|
"projectName": "jellyseerr",
|
||||||
"projectOwner": "Fallenbagel",
|
"projectOwner": "fallenbagel",
|
||||||
"repoType": "github",
|
"repoType": "github",
|
||||||
"repoHost": "https://github.com",
|
"repoHost": "https://github.com",
|
||||||
"skipCi": true,
|
"skipCi": true,
|
||||||
@@ -94,7 +94,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
|
||||||
"profile": "https://github.com/jab416171",
|
"profile": "https://github.com/jab416171",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc",
|
||||||
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -601,6 +603,114 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"design"
|
"design"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "ishanjain28",
|
||||||
|
"name": "Ishan Jain",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/7921368?v=4",
|
||||||
|
"profile": "https://ishanjain.me",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "michaelhthomas",
|
||||||
|
"name": "Michael Thomas",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18223295?v=4",
|
||||||
|
"profile": "http://michaelt.xyz",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "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>
|
||||||
|
|
||||||
|
|||||||
118
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-65-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/)**.
|
||||||
@@ -86,89 +86,103 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/Fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/undone37"><img src="https://avatars.githubusercontent.com/u/10513808?v=4?s=100" width="100px;" alt="undone37"/><br /><sub><b>undone37</b></sub></a><br /><a href="#translation-undone37" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/undone37"><img src="https://avatars.githubusercontent.com/u/10513808?v=4?s=100" width="100px;" alt="undone37"/><br /><sub><b>undone37</b></sub></a><br /><a href="#translation-undone37" title="Translation">🌍</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CheChu10"><img src="https://avatars.githubusercontent.com/u/32913133?v=4?s=100" width="100px;" alt="Chechu García"/><br /><sub><b>Chechu García</b></sub></a><br /><a href="#translation-CheChu10" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CheChu10"><img src="https://avatars.githubusercontent.com/u/32913133?v=4?s=100" width="100px;" alt="Chechu García"/><br /><sub><b>Chechu García</b></sub></a><br /><a href="#translation-CheChu10" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DimitriDR"><img src="https://avatars.githubusercontent.com/u/56969769?v=4?s=100" width="100px;" alt="Dimitri"/><br /><sub><b>Dimitri</b></sub></a><br /><a href="#translation-DimitriDR" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DimitriDR"><img src="https://avatars.githubusercontent.com/u/56969769?v=4?s=100" width="100px;" alt="Dimitri"/><br /><sub><b>Dimitri</b></sub></a><br /><a href="#translation-DimitriDR" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://geoffrey-coulaud.fr"><img src="https://avatars.githubusercontent.com/u/20744730?v=4?s=100" width="100px;" alt="Geoffrey Coulaud"/><br /><sub><b>Geoffrey Coulaud</b></sub></a><br /><a href="#translation-GeoffreyCoulaud" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://geoffrey-coulaud.fr"><img src="https://avatars.githubusercontent.com/u/20744730?v=4?s=100" width="100px;" alt="Geoffrey Coulaud"/><br /><sub><b>Geoffrey Coulaud</b></sub></a><br /><a href="#translation-GeoffreyCoulaud" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/d-fendrich"><img src="https://avatars.githubusercontent.com/u/27904138?v=4?s=100" width="100px;" alt="d-fendrich"/><br /><sub><b>d-fendrich</b></sub></a><br /><a href="#translation-d-fendrich" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/d-fendrich"><img src="https://avatars.githubusercontent.com/u/27904138?v=4?s=100" width="100px;" alt="d-fendrich"/><br /><sub><b>d-fendrich</b></sub></a><br /><a href="#translation-d-fendrich" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a> <a href="#maintenance-gauthier-th" title="Maintenance">🚧</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="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/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://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://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://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/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GkhnGRBZ"><img src="https://avatars.githubusercontent.com/u/127258824?v=4?s=100" width="100px;" alt="GkhnGRBZ"/><br /><sub><b>GkhnGRBZ</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=GkhnGRBZ" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GkhnGRBZ"><img src="https://avatars.githubusercontent.com/u/127258824?v=4?s=100" width="100px;" alt="GkhnGRBZ"/><br /><sub><b>GkhnGRBZ</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=GkhnGRBZ" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://benhaney.com"><img src="https://avatars.githubusercontent.com/u/31331498?v=4?s=100" width="100px;" alt="Ben Haney"/><br /><sub><b>Ben Haney</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=benhaney" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://benhaney.com"><img src="https://avatars.githubusercontent.com/u/31331498?v=4?s=100" width="100px;" alt="Ben Haney"/><br /><sub><b>Ben Haney</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benhaney" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Wunderharke"><img src="https://avatars.githubusercontent.com/u/5105672?v=4?s=100" width="100px;" alt="Wunderharke"/><br /><sub><b>Wunderharke</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Wunderharke" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Wunderharke"><img src="https://avatars.githubusercontent.com/u/5105672?v=4?s=100" width="100px;" alt="Wunderharke"/><br /><sub><b>Wunderharke</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Wunderharke" title="Documentation">📖</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/methbkts"><img src="https://avatars.githubusercontent.com/u/30674934?v=4?s=100" width="100px;" alt="Metin Bektas"/><br /><sub><b>Metin Bektas</b></sub></a><br /><a href="#infra-methbkts" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/methbkts"><img src="https://avatars.githubusercontent.com/u/30674934?v=4?s=100" width="100px;" alt="Metin Bektas"/><br /><sub><b>Metin Bektas</b></sub></a><br /><a href="#infra-methbkts" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrewkolda"><img src="https://avatars.githubusercontent.com/u/158614532?v=4?s=100" width="100px;" alt="andrewkolda"/><br /><sub><b>andrewkolda</b></sub></a><br /><a href="#design-andrewkolda" title="Design">🎨</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrewkolda"><img src="https://avatars.githubusercontent.com/u/158614532?v=4?s=100" width="100px;" alt="andrewkolda"/><br /><sub><b>andrewkolda</b></sub></a><br /><a href="#design-andrewkolda" title="Design">🎨</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://ishanjain.me"><img src="https://avatars.githubusercontent.com/u/7921368?v=4?s=100" width="100px;" alt="Ishan Jain"/><br /><sub><b>Ishan Jain</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ishanjain28" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="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>
|
||||||
@@ -286,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>
|
||||||
@@ -319,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": {
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ describe('General Settings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('modifies setting that requires restart', () => {
|
it('modifies setting that requires restart', () => {
|
||||||
cy.visit('/settings');
|
cy.visit('/settings/network');
|
||||||
|
|
||||||
cy.get('#trustProxy').click();
|
cy.get('#trustProxy').click();
|
||||||
cy.get('[data-testid=settings-main-form]').submit();
|
cy.get('[data-testid=settings-network-form]').submit();
|
||||||
cy.get('[data-testid=modal-title]').should(
|
cy.get('[data-testid=modal-title]').should(
|
||||||
'contain',
|
'contain',
|
||||||
'Server Restart Required'
|
'Server Restart Required'
|
||||||
@@ -26,7 +26,7 @@ describe('General Settings', () => {
|
|||||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
|
|
||||||
cy.get('[type=checkbox]#trustProxy').click();
|
cy.get('[type=checkbox]#trustProxy').click();
|
||||||
cy.get('[data-testid=settings-main-form]').submit();
|
cy.get('[data-testid=settings-network-form]').submit();
|
||||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,16 +160,10 @@ 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
|
||||||
trustProxy:
|
|
||||||
type: boolean
|
|
||||||
example: true
|
|
||||||
csrfProtection:
|
|
||||||
type: boolean
|
|
||||||
example: false
|
|
||||||
hideAvailable:
|
hideAvailable:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
@@ -191,12 +185,18 @@ components:
|
|||||||
enableSpecialEpisodes:
|
enableSpecialEpisodes:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
|
NetworkSettings:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
csrfProtection:
|
||||||
|
type: boolean
|
||||||
|
example: false
|
||||||
forceIpv4First:
|
forceIpv4First:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
dnsServers:
|
trustProxy:
|
||||||
type: string
|
type: boolean
|
||||||
example: '1.1.1.1'
|
example: true
|
||||||
PlexLibrary:
|
PlexLibrary:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1435,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
|
||||||
@@ -1966,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
|
||||||
@@ -2045,6 +2045,37 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/MainSettings'
|
$ref: '#/components/schemas/MainSettings'
|
||||||
|
/settings/network:
|
||||||
|
get:
|
||||||
|
summary: Get network settings
|
||||||
|
description: Retrieves all network settings in a JSON object.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MainSettings'
|
||||||
|
post:
|
||||||
|
summary: Update network settings
|
||||||
|
description: Updates network settings with the provided values.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NetworkSettings'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were sucessfully updated'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NetworkSettings'
|
||||||
/settings/main/regenerate:
|
/settings/main/regenerate:
|
||||||
post:
|
post:
|
||||||
summary: Get main settings with newly-generated API key
|
summary: Get main settings with newly-generated API key
|
||||||
@@ -3781,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
|
||||||
@@ -4389,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.
|
||||||
|
|||||||
36
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",
|
||||||
@@ -91,14 +91,14 @@
|
|||||||
"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",
|
"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",
|
||||||
@@ -106,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",
|
||||||
@@ -116,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",
|
||||||
@@ -146,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",
|
||||||
@@ -159,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",
|
||||||
|
|||||||
2762
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();
|
||||||
@@ -72,23 +72,20 @@ app
|
|||||||
|
|
||||||
// Load Settings
|
// Load Settings
|
||||||
const settings = await getSettings().load();
|
const settings = await getSettings().load();
|
||||||
restartFlag.initializeSettings(settings.main);
|
restartFlag.initializeSettings(settings);
|
||||||
|
|
||||||
// Check if we force IPv4 first
|
// Check if we force IPv4 first
|
||||||
if (process.env.forceIpv4First === 'true' || settings.main.forceIpv4First) {
|
if (
|
||||||
|
process.env.forceIpv4First === 'true' ||
|
||||||
|
settings.network.forceIpv4First
|
||||||
|
) {
|
||||||
dns.setDefaultResultOrder('ipv4first');
|
dns.setDefaultResultOrder('ipv4first');
|
||||||
net.setDefaultAutoSelectFamily(false);
|
net.setDefaultAutoSelectFamily(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.main.dnsServers.trim() !== '') {
|
|
||||||
dns.setServers(
|
|
||||||
settings.main.dnsServers.split(',').map((server) => server.trim())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register HTTP proxy
|
// Register HTTP proxy
|
||||||
if (settings.main.proxy.enabled) {
|
if (settings.network.proxy.enabled) {
|
||||||
await createCustomProxyAgent(settings.main.proxy);
|
await createCustomProxyAgent(settings.network.proxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate library types
|
// Migrate library types
|
||||||
@@ -143,7 +140,7 @@ app
|
|||||||
await DiscoverSlider.bootstrapSliders();
|
await DiscoverSlider.bootstrapSliders();
|
||||||
|
|
||||||
const server = express();
|
const server = express();
|
||||||
if (settings.main.trustProxy) {
|
if (settings.network.trustProxy) {
|
||||||
server.enable('trust proxy');
|
server.enable('trust proxy');
|
||||||
}
|
}
|
||||||
server.use(cookieParser());
|
server.use(cookieParser());
|
||||||
@@ -164,7 +161,7 @@ app
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (settings.main.csrfProtection) {
|
if (settings.network.csrfProtection) {
|
||||||
server.use(
|
server.use(
|
||||||
csurf({
|
csurf({
|
||||||
cookie: {
|
cookie: {
|
||||||
@@ -194,7 +191,7 @@ app
|
|||||||
cookie: {
|
cookie: {
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
|
sameSite: settings.network.csrfProtection ? 'strict' : 'lax',
|
||||||
secure: 'auto',
|
secure: 'auto',
|
||||||
},
|
},
|
||||||
store: new TypeormStore({
|
store: new TypeormStore({
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export interface MainSettings {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
csrfProtection: boolean;
|
|
||||||
cacheImages: boolean;
|
cacheImages: boolean;
|
||||||
defaultPermissions: number;
|
defaultPermissions: number;
|
||||||
defaultQuotas: {
|
defaultQuotas: {
|
||||||
@@ -129,13 +128,16 @@ export interface MainSettings {
|
|||||||
discoverRegion: string;
|
discoverRegion: string;
|
||||||
streamingRegion: string;
|
streamingRegion: string;
|
||||||
originalLanguage: string;
|
originalLanguage: string;
|
||||||
trustProxy: boolean;
|
|
||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
enableSpecialEpisodes: boolean;
|
enableSpecialEpisodes: boolean;
|
||||||
forceIpv4First: boolean;
|
|
||||||
dnsServers: string;
|
|
||||||
locale: string;
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkSettings {
|
||||||
|
csrfProtection: boolean;
|
||||||
|
forceIpv4First: boolean;
|
||||||
|
trustProxy: boolean;
|
||||||
proxy: ProxySettings;
|
proxy: ProxySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +317,7 @@ export interface AllSettings {
|
|||||||
public: PublicSettings;
|
public: PublicSettings;
|
||||||
notifications: NotificationSettings;
|
notifications: NotificationSettings;
|
||||||
jobs: Record<JobId, JobSettings>;
|
jobs: Record<JobId, JobSettings>;
|
||||||
|
network: NetworkSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||||
@@ -333,7 +336,6 @@ class Settings {
|
|||||||
apiKey: '',
|
apiKey: '',
|
||||||
applicationTitle: 'Jellyseerr',
|
applicationTitle: 'Jellyseerr',
|
||||||
applicationUrl: '',
|
applicationUrl: '',
|
||||||
csrfProtection: false,
|
|
||||||
cacheImages: false,
|
cacheImages: false,
|
||||||
defaultPermissions: Permission.REQUEST,
|
defaultPermissions: Permission.REQUEST,
|
||||||
defaultQuotas: {
|
defaultQuotas: {
|
||||||
@@ -347,23 +349,10 @@ class Settings {
|
|||||||
discoverRegion: '',
|
discoverRegion: '',
|
||||||
streamingRegion: '',
|
streamingRegion: '',
|
||||||
originalLanguage: '',
|
originalLanguage: '',
|
||||||
trustProxy: false,
|
|
||||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||||
partialRequestsEnabled: true,
|
partialRequestsEnabled: true,
|
||||||
enableSpecialEpisodes: false,
|
enableSpecialEpisodes: false,
|
||||||
forceIpv4First: false,
|
|
||||||
dnsServers: '',
|
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
proxy: {
|
|
||||||
enabled: false,
|
|
||||||
hostname: '',
|
|
||||||
port: 8080,
|
|
||||||
useSsl: false,
|
|
||||||
user: '',
|
|
||||||
password: '',
|
|
||||||
bypassFilter: '',
|
|
||||||
bypassLocalAddresses: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -516,6 +505,21 @@ class Settings {
|
|||||||
schedule: '0 0 5 * * *',
|
schedule: '0 0 5 * * *',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
network: {
|
||||||
|
csrfProtection: false,
|
||||||
|
trustProxy: false,
|
||||||
|
forceIpv4First: false,
|
||||||
|
proxy: {
|
||||||
|
enabled: false,
|
||||||
|
hostname: '',
|
||||||
|
port: 8080,
|
||||||
|
useSsl: false,
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
bypassFilter: '',
|
||||||
|
bypassLocalAddresses: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (initialSettings) {
|
if (initialSettings) {
|
||||||
this.data = merge(this.data, initialSettings);
|
this.data = merge(this.data, initialSettings);
|
||||||
@@ -627,6 +631,14 @@ class Settings {
|
|||||||
this.data.jobs = data;
|
this.data.jobs = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get network(): NetworkSettings {
|
||||||
|
return this.data.network;
|
||||||
|
}
|
||||||
|
|
||||||
|
set network(data: NetworkSettings) {
|
||||||
|
this.data.network = data;
|
||||||
|
}
|
||||||
|
|
||||||
get clientId(): string {
|
get clientId(): string {
|
||||||
return this.data.clientId;
|
return this.data.clientId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
|
const migrateNetworkSettings = (settings: any): AllSettings => {
|
||||||
|
if (settings.network) {
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
const newSettings = { ...settings };
|
||||||
|
newSettings.network = {
|
||||||
|
...settings.network,
|
||||||
|
csrfProtection: settings.main.csrfProtection ?? false,
|
||||||
|
trustProxy: settings.main.trustProxy ?? false,
|
||||||
|
forceIpv4First: settings.main.forceIpv4First ?? false,
|
||||||
|
proxy: settings.main.proxy ?? {
|
||||||
|
enabled: false,
|
||||||
|
hostname: '',
|
||||||
|
port: 8080,
|
||||||
|
useSsl: false,
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
bypassFilter: '',
|
||||||
|
bypassLocalAddresses: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
delete settings.main.csrfProtection;
|
||||||
|
delete settings.main.trustProxy;
|
||||||
|
delete settings.main.forceIpv4First;
|
||||||
|
delete settings.main.proxy;
|
||||||
|
return newSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default migrateNetworkSettings;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -158,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,
|
||||||
@@ -274,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'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -446,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,
|
||||||
@@ -584,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,
|
||||||
@@ -674,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ settingsRoutes.post('/main', async (req, res) => {
|
|||||||
return res.status(200).json(settings.main);
|
return res.status(200).json(settings.main);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
settingsRoutes.get('/network', (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
res.status(200).json(settings.network);
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsRoutes.post('/network', async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
settings.network = merge(settings.network, req.body);
|
||||||
|
await settings.save();
|
||||||
|
|
||||||
|
return res.status(200).json(settings.network);
|
||||||
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/main/regenerate', async (req, res, next) => {
|
settingsRoutes.post('/main/regenerate', async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
@@ -337,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;
|
||||||
|
}
|
||||||
@@ -6,10 +6,11 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
|||||||
export default async function createCustomProxyAgent(
|
export default async function createCustomProxyAgent(
|
||||||
proxySettings: ProxySettings
|
proxySettings: ProxySettings
|
||||||
) {
|
) {
|
||||||
const defaultAgent = new Agent();
|
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,12 +60,10 @@ export default async function createCustomProxyAgent(
|
|||||||
':' +
|
':' +
|
||||||
proxySettings.port,
|
proxySettings.port,
|
||||||
token,
|
token,
|
||||||
interceptors: {
|
keepAliveTimeout: 5000,
|
||||||
Client: [noProxyInterceptor],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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',
|
||||||
@@ -94,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import type { MainSettings } from '@server/lib/settings';
|
import type { AllSettings, NetworkSettings } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
class RestartFlag {
|
class RestartFlag {
|
||||||
private settings: MainSettings;
|
private networkSettings: NetworkSettings;
|
||||||
|
|
||||||
public initializeSettings(settings: MainSettings): void {
|
public initializeSettings(settings: AllSettings): void {
|
||||||
this.settings = { ...settings };
|
this.networkSettings = {
|
||||||
|
...settings.network,
|
||||||
|
proxy: { ...settings.network.proxy },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public isSet(): boolean {
|
public isSet(): boolean {
|
||||||
const settings = getSettings().main;
|
const networkSettings = getSettings().network;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.settings.csrfProtection !== settings.csrfProtection ||
|
this.networkSettings.csrfProtection !== networkSettings.csrfProtection ||
|
||||||
this.settings.trustProxy !== settings.trustProxy ||
|
this.networkSettings.trustProxy !== networkSettings.trustProxy ||
|
||||||
this.settings.proxy.enabled !== settings.proxy.enabled ||
|
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled ||
|
||||||
this.settings.forceIpv4First !== settings.forceIpv4First ||
|
this.networkSettings.forceIpv4First !== networkSettings.forceIpv4First
|
||||||
this.settings.dnsServers !== settings.dnsServers
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
});
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
{({ errors, touched, isSubmitting, isValid }) => {
|
{({ errors, touched, isSubmitting, isValid }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form>
|
<Form data-form-type="login">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
|
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
|
||||||
{intl.formatMessage(messages.loginwithapp, {
|
{intl.formatMessage(messages.loginwithapp, {
|
||||||
@@ -140,6 +140,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder={intl.formatMessage(messages.username)}
|
placeholder={intl.formatMessage(messages.username)}
|
||||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||||
|
data-form-type="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.username && touched.username && (
|
{errors.username && touched.username && (
|
||||||
@@ -157,6 +158,9 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder={intl.formatMessage(messages.password)}
|
placeholder={intl.formatMessage(messages.password)}
|
||||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||||
|
data-form-type="password"
|
||||||
|
data-1pignore="false"
|
||||||
|
data-lpignore="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
{({ errors, touched, isSubmitting, isValid }) => {
|
{({ errors, touched, isSubmitting, isValid }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form>
|
<Form data-form-type="login">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
|
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
|
||||||
{intl.formatMessage(messages.loginwithapp, {
|
{intl.formatMessage(messages.loginwithapp, {
|
||||||
@@ -94,6 +94,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
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"
|
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +114,10 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
placeholder={intl.formatMessage(messages.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"
|
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||||
|
data-1pignore="false"
|
||||||
|
data-lpignore="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -190,7 +190,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
mediaLinks.push({
|
mediaLinks.push({
|
||||||
text: getAvalaibleMediaServerName(),
|
text: getAvailableMediaServerName(),
|
||||||
url: plexUrl,
|
url: plexUrl,
|
||||||
svg: <PlayIcon />,
|
svg: <PlayIcon />,
|
||||||
});
|
});
|
||||||
@@ -204,7 +204,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
mediaLinks.push({
|
mediaLinks.push({
|
||||||
text: getAvalaible4kMediaServerName(),
|
text: getAvailable4kMediaServerName(),
|
||||||
url: plexUrl4k,
|
url: plexUrl4k,
|
||||||
svg: <PlayIcon />,
|
svg: <PlayIcon />,
|
||||||
});
|
});
|
||||||
@@ -292,7 +292,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
(provider) => provider.iso_3166_1 === streamingRegion
|
(provider) => provider.iso_3166_1 === streamingRegion
|
||||||
)?.flatrate ?? [];
|
)?.flatrate ?? [];
|
||||||
|
|
||||||
function getAvalaibleMediaServerName() {
|
function getAvailableMediaServerName() {
|
||||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||||
}
|
}
|
||||||
@@ -304,7 +304,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
|
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvalaible4kMediaServerName() {
|
function getAvailable4kMediaServerName() {
|
||||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const messages = defineMessages('components.Settings', {
|
|||||||
menuPlexSettings: 'Plex',
|
menuPlexSettings: 'Plex',
|
||||||
menuJellyfinSettings: '{mediaServerName}',
|
menuJellyfinSettings: '{mediaServerName}',
|
||||||
menuServices: 'Services',
|
menuServices: 'Services',
|
||||||
|
menuNetwork: 'Network',
|
||||||
menuNotifications: 'Notifications',
|
menuNotifications: 'Notifications',
|
||||||
menuLogs: 'Logs',
|
menuLogs: 'Logs',
|
||||||
menuJobs: 'Jobs & Cache',
|
menuJobs: 'Jobs & Cache',
|
||||||
@@ -53,6 +54,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
|
|||||||
route: '/settings/services',
|
route: '/settings/services',
|
||||||
regex: /^\/settings\/services/,
|
regex: /^\/settings\/services/,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.menuNetwork),
|
||||||
|
route: '/settings/network',
|
||||||
|
regex: /^\/settings\/network/,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: intl.formatMessage(messages.menuNotifications),
|
text: intl.formatMessage(messages.menuNotifications),
|
||||||
route: '/settings/notifications/email',
|
route: '/settings/notifications/email',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Button from '@app/components/Common/Button';
|
|||||||
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 SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
import Tooltip from '@app/components/Common/Tooltip';
|
|
||||||
import LanguageSelector from '@app/components/LanguageSelector';
|
import LanguageSelector from '@app/components/LanguageSelector';
|
||||||
import RegionSelector from '@app/components/RegionSelector';
|
import RegionSelector from '@app/components/RegionSelector';
|
||||||
import CopyButton from '@app/components/Settings/CopyButton';
|
import CopyButton from '@app/components/Settings/CopyButton';
|
||||||
@@ -42,39 +41,15 @@ const messages = defineMessages('components.Settings.SettingsMain', {
|
|||||||
toastSettingsSuccess: 'Settings saved successfully!',
|
toastSettingsSuccess: 'Settings saved successfully!',
|
||||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||||
hideAvailable: 'Hide Available Media',
|
hideAvailable: 'Hide Available Media',
|
||||||
csrfProtection: 'Enable CSRF Protection',
|
|
||||||
csrfProtectionTip: 'Set external API access to read-only (requires HTTPS)',
|
|
||||||
csrfProtectionHoverTip:
|
|
||||||
'Do NOT enable this setting unless you understand what you are doing!',
|
|
||||||
cacheImages: 'Enable Image Caching',
|
cacheImages: 'Enable Image Caching',
|
||||||
cacheImagesTip:
|
cacheImagesTip:
|
||||||
'Cache externally sourced images (requires a significant amount of disk space)',
|
'Cache externally sourced images (requires a significant amount of disk space)',
|
||||||
trustProxy: 'Enable Proxy Support',
|
|
||||||
trustProxyTip:
|
|
||||||
'Allow Jellyseerr to correctly register client IP addresses behind a proxy',
|
|
||||||
validationApplicationTitle: 'You must provide an application title',
|
validationApplicationTitle: 'You must provide an application title',
|
||||||
validationApplicationUrl: 'You must provide a valid URL',
|
validationApplicationUrl: 'You must provide a valid URL',
|
||||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
partialRequestsEnabled: 'Allow Partial Series Requests',
|
partialRequestsEnabled: 'Allow Partial Series Requests',
|
||||||
enableSpecialEpisodes: 'Allow Special Episodes Requests',
|
enableSpecialEpisodes: 'Allow Special Episodes Requests',
|
||||||
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]"',
|
|
||||||
locale: 'Display Language',
|
locale: 'Display Language',
|
||||||
proxyEnabled: 'HTTP(S) Proxy',
|
|
||||||
proxyHostname: 'Proxy Hostname',
|
|
||||||
proxyPort: 'Proxy Port',
|
|
||||||
proxySsl: 'Use SSL For Proxy',
|
|
||||||
proxyUser: 'Proxy Username',
|
|
||||||
proxyPassword: 'Proxy Password',
|
|
||||||
proxyBypassFilter: 'Proxy Ignored Addresses',
|
|
||||||
proxyBypassFilterTip:
|
|
||||||
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
|
||||||
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
|
|
||||||
validationProxyPort: 'You must provide a valid port',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const SettingsMain = () => {
|
const SettingsMain = () => {
|
||||||
@@ -105,12 +80,6 @@ const SettingsMain = () => {
|
|||||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||||
(value) => !value || !value.endsWith('/')
|
(value) => !value || !value.endsWith('/')
|
||||||
),
|
),
|
||||||
proxyPort: Yup.number().when('proxyEnabled', {
|
|
||||||
is: (proxyEnabled: boolean) => proxyEnabled,
|
|
||||||
then: Yup.number().required(
|
|
||||||
intl.formatMessage(messages.validationProxyPort)
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const regenerate = async () => {
|
const regenerate = async () => {
|
||||||
@@ -158,7 +127,6 @@ const SettingsMain = () => {
|
|||||||
initialValues={{
|
initialValues={{
|
||||||
applicationTitle: data?.applicationTitle,
|
applicationTitle: data?.applicationTitle,
|
||||||
applicationUrl: data?.applicationUrl,
|
applicationUrl: data?.applicationUrl,
|
||||||
csrfProtection: data?.csrfProtection,
|
|
||||||
hideAvailable: data?.hideAvailable,
|
hideAvailable: data?.hideAvailable,
|
||||||
locale: data?.locale ?? 'en',
|
locale: data?.locale ?? 'en',
|
||||||
discoverRegion: data?.discoverRegion,
|
discoverRegion: data?.discoverRegion,
|
||||||
@@ -166,18 +134,7 @@ const SettingsMain = () => {
|
|||||||
streamingRegion: data?.streamingRegion || 'US',
|
streamingRegion: data?.streamingRegion || 'US',
|
||||||
partialRequestsEnabled: data?.partialRequestsEnabled,
|
partialRequestsEnabled: data?.partialRequestsEnabled,
|
||||||
enableSpecialEpisodes: data?.enableSpecialEpisodes,
|
enableSpecialEpisodes: data?.enableSpecialEpisodes,
|
||||||
forceIpv4First: data?.forceIpv4First,
|
|
||||||
dnsServers: data?.dnsServers,
|
|
||||||
trustProxy: data?.trustProxy,
|
|
||||||
cacheImages: data?.cacheImages,
|
cacheImages: data?.cacheImages,
|
||||||
proxyEnabled: data?.proxy?.enabled,
|
|
||||||
proxyHostname: data?.proxy?.hostname,
|
|
||||||
proxyPort: data?.proxy?.port,
|
|
||||||
proxySsl: data?.proxy?.useSsl,
|
|
||||||
proxyUser: data?.proxy?.user,
|
|
||||||
proxyPassword: data?.proxy?.password,
|
|
||||||
proxyBypassFilter: data?.proxy?.bypassFilter,
|
|
||||||
proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses,
|
|
||||||
}}
|
}}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
validationSchema={MainSettingsSchema}
|
validationSchema={MainSettingsSchema}
|
||||||
@@ -191,7 +148,6 @@ const SettingsMain = () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
applicationTitle: values.applicationTitle,
|
applicationTitle: values.applicationTitle,
|
||||||
applicationUrl: values.applicationUrl,
|
applicationUrl: values.applicationUrl,
|
||||||
csrfProtection: values.csrfProtection,
|
|
||||||
hideAvailable: values.hideAvailable,
|
hideAvailable: values.hideAvailable,
|
||||||
locale: values.locale,
|
locale: values.locale,
|
||||||
discoverRegion: values.discoverRegion,
|
discoverRegion: values.discoverRegion,
|
||||||
@@ -199,20 +155,7 @@ const SettingsMain = () => {
|
|||||||
originalLanguage: values.originalLanguage,
|
originalLanguage: values.originalLanguage,
|
||||||
partialRequestsEnabled: values.partialRequestsEnabled,
|
partialRequestsEnabled: values.partialRequestsEnabled,
|
||||||
enableSpecialEpisodes: values.enableSpecialEpisodes,
|
enableSpecialEpisodes: values.enableSpecialEpisodes,
|
||||||
forceIpv4First: values.forceIpv4First,
|
|
||||||
dnsServers: values.dnsServers,
|
|
||||||
trustProxy: values.trustProxy,
|
|
||||||
cacheImages: values.cacheImages,
|
cacheImages: values.cacheImages,
|
||||||
proxy: {
|
|
||||||
enabled: values.proxyEnabled,
|
|
||||||
hostname: values.proxyHostname,
|
|
||||||
port: values.proxyPort,
|
|
||||||
useSsl: values.proxySsl,
|
|
||||||
user: values.proxyUser,
|
|
||||||
password: values.proxyPassword,
|
|
||||||
bypassFilter: values.proxyBypassFilter,
|
|
||||||
bypassLocalAddresses: values.proxyBypassLocalAddresses,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
@@ -321,58 +264,6 @@ const SettingsMain = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="trustProxy" className="checkbox-label">
|
|
||||||
<span className="mr-2">
|
|
||||||
{intl.formatMessage(messages.trustProxy)}
|
|
||||||
</span>
|
|
||||||
<SettingsBadge badgeType="restartRequired" />
|
|
||||||
<span className="label-tip">
|
|
||||||
{intl.formatMessage(messages.trustProxyTip)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<Field
|
|
||||||
type="checkbox"
|
|
||||||
id="trustProxy"
|
|
||||||
name="trustProxy"
|
|
||||||
onChange={() => {
|
|
||||||
setFieldValue('trustProxy', !values.trustProxy);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="csrfProtection" className="checkbox-label">
|
|
||||||
<span className="mr-2">
|
|
||||||
{intl.formatMessage(messages.csrfProtection)}
|
|
||||||
</span>
|
|
||||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
|
||||||
<SettingsBadge badgeType="restartRequired" />
|
|
||||||
<span className="label-tip">
|
|
||||||
{intl.formatMessage(messages.csrfProtectionTip)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<Tooltip
|
|
||||||
content={intl.formatMessage(
|
|
||||||
messages.csrfProtectionHoverTip
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="checkbox"
|
|
||||||
id="csrfProtection"
|
|
||||||
name="csrfProtection"
|
|
||||||
onChange={() => {
|
|
||||||
setFieldValue(
|
|
||||||
'csrfProtection',
|
|
||||||
!values.csrfProtection
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="cacheImages" className="checkbox-label">
|
<label htmlFor="cacheImages" className="checkbox-label">
|
||||||
<span className="mr-2">
|
<span className="mr-2">
|
||||||
@@ -534,231 +425,6 @@ const SettingsMain = () => {
|
|||||||
/>
|
/>
|
||||||
</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">
|
|
||||||
<label htmlFor="proxyEnabled" className="checkbox-label">
|
|
||||||
<span className="mr-2">
|
|
||||||
{intl.formatMessage(messages.proxyEnabled)}
|
|
||||||
</span>
|
|
||||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
|
||||||
<SettingsBadge badgeType="restartRequired" />
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<Field
|
|
||||||
type="checkbox"
|
|
||||||
id="proxyEnabled"
|
|
||||||
name="proxyEnabled"
|
|
||||||
onChange={() => {
|
|
||||||
setFieldValue('proxyEnabled', !values.proxyEnabled);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{values.proxyEnabled && (
|
|
||||||
<>
|
|
||||||
<div className="mr-2 ml-4">
|
|
||||||
<div className="form-row">
|
|
||||||
<label
|
|
||||||
htmlFor="proxyHostname"
|
|
||||||
className="checkbox-label"
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.proxyHostname)}
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field
|
|
||||||
id="proxyHostname"
|
|
||||||
name="proxyHostname"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.proxyHostname &&
|
|
||||||
touched.proxyHostname &&
|
|
||||||
typeof errors.proxyHostname === 'string' && (
|
|
||||||
<div className="error">
|
|
||||||
{errors.proxyHostname}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="proxyPort" className="checkbox-label">
|
|
||||||
{intl.formatMessage(messages.proxyPort)}
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field
|
|
||||||
id="proxyPort"
|
|
||||||
name="proxyPort"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.proxyPort &&
|
|
||||||
touched.proxyPort &&
|
|
||||||
typeof errors.proxyPort === 'string' && (
|
|
||||||
<div className="error">{errors.proxyPort}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="proxySsl" className="checkbox-label">
|
|
||||||
{intl.formatMessage(messages.proxySsl)}
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<Field
|
|
||||||
type="checkbox"
|
|
||||||
id="proxySsl"
|
|
||||||
name="proxySsl"
|
|
||||||
onChange={() => {
|
|
||||||
setFieldValue('proxySsl', !values.proxySsl);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label htmlFor="proxyUser" className="checkbox-label">
|
|
||||||
{intl.formatMessage(messages.proxyUser)}
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field
|
|
||||||
id="proxyUser"
|
|
||||||
name="proxyUser"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.proxyUser &&
|
|
||||||
touched.proxyUser &&
|
|
||||||
typeof errors.proxyUser === 'string' && (
|
|
||||||
<div className="error">{errors.proxyUser}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label
|
|
||||||
htmlFor="proxyPassword"
|
|
||||||
className="checkbox-label"
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.proxyPassword)}
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field
|
|
||||||
id="proxyPassword"
|
|
||||||
name="proxyPassword"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.proxyPassword &&
|
|
||||||
touched.proxyPassword &&
|
|
||||||
typeof errors.proxyPassword === 'string' && (
|
|
||||||
<div className="error">
|
|
||||||
{errors.proxyPassword}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label
|
|
||||||
htmlFor="proxyBypassFilter"
|
|
||||||
className="checkbox-label"
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.proxyBypassFilter)}
|
|
||||||
<span className="label-tip">
|
|
||||||
{intl.formatMessage(messages.proxyBypassFilterTip)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field
|
|
||||||
id="proxyBypassFilter"
|
|
||||||
name="proxyBypassFilter"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.proxyBypassFilter &&
|
|
||||||
touched.proxyBypassFilter &&
|
|
||||||
typeof errors.proxyBypassFilter === 'string' && (
|
|
||||||
<div className="error">
|
|
||||||
{errors.proxyBypassFilter}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label
|
|
||||||
htmlFor="proxyBypassLocalAddresses"
|
|
||||||
className="checkbox-label"
|
|
||||||
>
|
|
||||||
{intl.formatMessage(
|
|
||||||
messages.proxyBypassLocalAddresses
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<Field
|
|
||||||
type="checkbox"
|
|
||||||
id="proxyBypassLocalAddresses"
|
|
||||||
name="proxyBypassLocalAddresses"
|
|
||||||
onChange={() => {
|
|
||||||
setFieldValue(
|
|
||||||
'proxyBypassLocalAddresses',
|
|
||||||
!values.proxyBypassLocalAddresses
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
|||||||
451
src/components/Settings/SettingsNetwork/index.tsx
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import SettingsBadge from '@app/components/Settings/SettingsBadge';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
|
import type { NetworkSettings } from '@server/lib/settings';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Settings.SettingsNetwork', {
|
||||||
|
toastSettingsSuccess: 'Settings saved successfully!',
|
||||||
|
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||||
|
network: 'Network',
|
||||||
|
networksettings: 'Network Settings',
|
||||||
|
networksettingsDescription:
|
||||||
|
'Configure network settings for your Jellyseerr instance.',
|
||||||
|
csrfProtection: 'Enable CSRF Protection',
|
||||||
|
csrfProtectionTip: 'Set external API access to read-only (requires HTTPS)',
|
||||||
|
csrfProtectionHoverTip:
|
||||||
|
'Do NOT enable this setting unless you understand what you are doing!',
|
||||||
|
trustProxy: 'Enable Proxy Support',
|
||||||
|
trustProxyTip:
|
||||||
|
'Allow Jellyseerr to correctly register client IP addresses behind a proxy',
|
||||||
|
proxyEnabled: 'HTTP(S) Proxy',
|
||||||
|
proxyHostname: 'Proxy Hostname',
|
||||||
|
proxyPort: 'Proxy Port',
|
||||||
|
proxySsl: 'Use SSL For Proxy',
|
||||||
|
proxyUser: 'Proxy Username',
|
||||||
|
proxyPassword: 'Proxy Password',
|
||||||
|
proxyBypassFilter: 'Proxy Ignored Addresses',
|
||||||
|
proxyBypassFilterTip:
|
||||||
|
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
||||||
|
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
|
||||||
|
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 SettingsNetwork = () => {
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const intl = useIntl();
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
mutate: revalidate,
|
||||||
|
} = useSWR<NetworkSettings>('/api/v1/settings/network');
|
||||||
|
|
||||||
|
const NetworkSettingsSchema = Yup.object().shape({
|
||||||
|
proxyPort: Yup.number().when('proxyEnabled', {
|
||||||
|
is: (proxyEnabled: boolean) => proxyEnabled,
|
||||||
|
then: Yup.number().required(
|
||||||
|
intl.formatMessage(messages.validationProxyPort)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data && !error) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
title={[
|
||||||
|
intl.formatMessage(messages.network),
|
||||||
|
intl.formatMessage(globalMessages.settings),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="heading">
|
||||||
|
{intl.formatMessage(messages.networksettings)}
|
||||||
|
</h3>
|
||||||
|
<p className="description">
|
||||||
|
{intl.formatMessage(messages.networksettingsDescription)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="section">
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
csrfProtection: data?.csrfProtection,
|
||||||
|
forceIpv4First: data?.forceIpv4First,
|
||||||
|
trustProxy: data?.trustProxy,
|
||||||
|
proxyEnabled: data?.proxy?.enabled,
|
||||||
|
proxyHostname: data?.proxy?.hostname,
|
||||||
|
proxyPort: data?.proxy?.port,
|
||||||
|
proxySsl: data?.proxy?.useSsl,
|
||||||
|
proxyUser: data?.proxy?.user,
|
||||||
|
proxyPassword: data?.proxy?.password,
|
||||||
|
proxyBypassFilter: data?.proxy?.bypassFilter,
|
||||||
|
proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses,
|
||||||
|
}}
|
||||||
|
enableReinitialize
|
||||||
|
validationSchema={NetworkSettingsSchema}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/settings/network', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
csrfProtection: values.csrfProtection,
|
||||||
|
forceIpv4First: values.forceIpv4First,
|
||||||
|
trustProxy: values.trustProxy,
|
||||||
|
proxy: {
|
||||||
|
enabled: values.proxyEnabled,
|
||||||
|
hostname: values.proxyHostname,
|
||||||
|
port: values.proxyPort,
|
||||||
|
useSsl: values.proxySsl,
|
||||||
|
user: values.proxyUser,
|
||||||
|
password: values.proxyPassword,
|
||||||
|
bypassFilter: values.proxyBypassFilter,
|
||||||
|
bypassLocalAddresses: values.proxyBypassLocalAddresses,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
mutate('/api/v1/settings/public');
|
||||||
|
mutate('/api/v1/status');
|
||||||
|
|
||||||
|
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'success',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.toastSettingsFailure), {
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
|
values,
|
||||||
|
setFieldValue,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Form className="section" data-testid="settings-network-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="trustProxy" className="checkbox-label">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.trustProxy)}
|
||||||
|
</span>
|
||||||
|
<SettingsBadge badgeType="restartRequired" />
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.trustProxyTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="trustProxy"
|
||||||
|
name="trustProxy"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue('trustProxy', !values.trustProxy);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="csrfProtection" className="checkbox-label">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.csrfProtection)}
|
||||||
|
</span>
|
||||||
|
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||||
|
<SettingsBadge badgeType="restartRequired" />
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.csrfProtectionTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Tooltip
|
||||||
|
content={intl.formatMessage(
|
||||||
|
messages.csrfProtectionHoverTip
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="csrfProtection"
|
||||||
|
name="csrfProtection"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue(
|
||||||
|
'csrfProtection',
|
||||||
|
!values.csrfProtection
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="proxyEnabled" className="checkbox-label">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.proxyEnabled)}
|
||||||
|
</span>
|
||||||
|
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||||
|
<SettingsBadge badgeType="restartRequired" />
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="proxyEnabled"
|
||||||
|
name="proxyEnabled"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue('proxyEnabled', !values.proxyEnabled);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{values.proxyEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="mr-2 ml-4">
|
||||||
|
<div className="form-row">
|
||||||
|
<label
|
||||||
|
htmlFor="proxyHostname"
|
||||||
|
className="checkbox-label"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.proxyHostname)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="proxyHostname"
|
||||||
|
name="proxyHostname"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.proxyHostname &&
|
||||||
|
touched.proxyHostname &&
|
||||||
|
typeof errors.proxyHostname === 'string' && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.proxyHostname}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="proxyPort" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.proxyPort)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="proxyPort"
|
||||||
|
name="proxyPort"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.proxyPort &&
|
||||||
|
touched.proxyPort &&
|
||||||
|
typeof errors.proxyPort === 'string' && (
|
||||||
|
<div className="error">{errors.proxyPort}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="proxySsl" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.proxySsl)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="proxySsl"
|
||||||
|
name="proxySsl"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue('proxySsl', !values.proxySsl);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="proxyUser" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.proxyUser)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="proxyUser"
|
||||||
|
name="proxyUser"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.proxyUser &&
|
||||||
|
touched.proxyUser &&
|
||||||
|
typeof errors.proxyUser === 'string' && (
|
||||||
|
<div className="error">{errors.proxyUser}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label
|
||||||
|
htmlFor="proxyPassword"
|
||||||
|
className="checkbox-label"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.proxyPassword)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="proxyPassword"
|
||||||
|
name="proxyPassword"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.proxyPassword &&
|
||||||
|
touched.proxyPassword &&
|
||||||
|
typeof errors.proxyPassword === 'string' && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.proxyPassword}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label
|
||||||
|
htmlFor="proxyBypassFilter"
|
||||||
|
className="checkbox-label"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.proxyBypassFilter)}
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.proxyBypassFilterTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="proxyBypassFilter"
|
||||||
|
name="proxyBypassFilter"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.proxyBypassFilter &&
|
||||||
|
touched.proxyBypassFilter &&
|
||||||
|
typeof errors.proxyBypassFilter === 'string' && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.proxyBypassFilter}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label
|
||||||
|
htmlFor="proxyBypassLocalAddresses"
|
||||||
|
className="checkbox-label"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages.proxyBypassLocalAddresses
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="proxyBypassLocalAddresses"
|
||||||
|
name="proxyBypassLocalAddresses"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue(
|
||||||
|
'proxyBypassLocalAddresses',
|
||||||
|
!values.proxyBypassLocalAddresses
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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="flex justify-end">
|
||||||
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
>
|
||||||
|
<ArrowDownOnSquareIcon />
|
||||||
|
<span>
|
||||||
|
{isSubmitting
|
||||||
|
? intl.formatMessage(globalMessages.saving)
|
||||||
|
: intl.formatMessage(globalMessages.save)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,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>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -198,6 +198,11 @@ function JellyfinSetup({
|
|||||||
messages.hostname,
|
messages.hostname,
|
||||||
mediaServerFormatValues
|
mediaServerFormatValues
|
||||||
)}
|
)}
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.hostname && touched.hostname && (
|
{errors.hostname && touched.hostname && (
|
||||||
@@ -282,6 +287,11 @@ function JellyfinSetup({
|
|||||||
name="email"
|
name="email"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={intl.formatMessage(messages.email)}
|
placeholder={intl.formatMessage(messages.email)}
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.email && touched.email && (
|
{errors.email && touched.email && (
|
||||||
@@ -298,6 +308,11 @@ function JellyfinSetup({
|
|||||||
name="username"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={intl.formatMessage(messages.username)}
|
placeholder={intl.formatMessage(messages.username)}
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.username && touched.username && (
|
{errors.username && touched.username && (
|
||||||
@@ -314,6 +329,11 @@ function JellyfinSetup({
|
|||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={intl.formatMessage(messages.password)}
|
placeholder={intl.formatMessage(messages.password)}
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.password && touched.password && (
|
{errors.password && touched.password && (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import RequestModal from '@app/components/RequestModal';
|
|||||||
import ErrorCard from '@app/components/TitleCard/ErrorCard';
|
import ErrorCard from '@app/components/TitleCard/ErrorCard';
|
||||||
import Placeholder from '@app/components/TitleCard/Placeholder';
|
import Placeholder from '@app/components/TitleCard/Placeholder';
|
||||||
import { useIsTouch } from '@app/hooks/useIsTouch';
|
import { useIsTouch } from '@app/hooks/useIsTouch';
|
||||||
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 defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { withProperties } from '@app/utils/typeHelpers';
|
import { withProperties } from '@app/utils/typeHelpers';
|
||||||
@@ -352,7 +352,7 @@ const TitleCard = ({
|
|||||||
src={
|
src={
|
||||||
image
|
image
|
||||||
? `https://image.tmdb.org/t/p/w300_and_h450_face${image}`
|
? `https://image.tmdb.org/t/p/w300_and_h450_face${image}`
|
||||||
: `/images/overseerr_poster_not_found_logo_top.png`
|
: `/images/jellyseerr_poster_not_found_logo_top.png`
|
||||||
}
|
}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
fill
|
fill
|
||||||
@@ -375,24 +375,25 @@ const TitleCard = ({
|
|||||||
</div>
|
</div>
|
||||||
{showDetail && currentStatus !== MediaStatus.BLACKLISTED && (
|
{showDetail && currentStatus !== MediaStatus.BLACKLISTED && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{toggleWatchlist ? (
|
{user?.userType !== UserType.PLEX &&
|
||||||
<Button
|
(toggleWatchlist ? (
|
||||||
buttonType={'ghost'}
|
<Button
|
||||||
className="z-40"
|
buttonType={'ghost'}
|
||||||
buttonSize={'sm'}
|
className="z-40"
|
||||||
onClick={onClickWatchlistBtn}
|
buttonSize={'sm'}
|
||||||
>
|
onClick={onClickWatchlistBtn}
|
||||||
<StarIcon className={'h-3 text-amber-300'} />
|
>
|
||||||
</Button>
|
<StarIcon className={'h-3 text-amber-300'} />
|
||||||
) : (
|
</Button>
|
||||||
<Button
|
) : (
|
||||||
className="z-40"
|
<Button
|
||||||
buttonSize={'sm'}
|
className="z-40"
|
||||||
onClick={onClickDeleteWatchlistBtn}
|
buttonSize={'sm'}
|
||||||
>
|
onClick={onClickDeleteWatchlistBtn}
|
||||||
<MinusCircleIcon className={'h-3'} />
|
>
|
||||||
</Button>
|
<MinusCircleIcon className={'h-3'} />
|
||||||
)}
|
</Button>
|
||||||
|
))}
|
||||||
{showHideButton &&
|
{showHideButton &&
|
||||||
currentStatus !== MediaStatus.PROCESSING &&
|
currentStatus !== MediaStatus.PROCESSING &&
|
||||||
currentStatus !== MediaStatus.AVAILABLE &&
|
currentStatus !== MediaStatus.AVAILABLE &&
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import Season from '@app/components/TvDetails/Season';
|
|||||||
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 Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||||
@@ -187,7 +187,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
mediaLinks.push({
|
mediaLinks.push({
|
||||||
text: getAvalaibleMediaServerName(),
|
text: getAvailableMediaServerName(),
|
||||||
url: plexUrl,
|
url: plexUrl,
|
||||||
svg: <PlayIcon />,
|
svg: <PlayIcon />,
|
||||||
});
|
});
|
||||||
@@ -201,7 +201,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
mediaLinks.push({
|
mediaLinks.push({
|
||||||
text: getAvalaible4kMediaServerName(),
|
text: getAvailable4kMediaServerName(),
|
||||||
url: plexUrl4k,
|
url: plexUrl4k,
|
||||||
svg: <PlayIcon />,
|
svg: <PlayIcon />,
|
||||||
});
|
});
|
||||||
@@ -322,7 +322,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
(provider) => provider.iso_3166_1 === streamingRegion
|
(provider) => provider.iso_3166_1 === streamingRegion
|
||||||
)?.flatrate ?? [];
|
)?.flatrate ?? [];
|
||||||
|
|
||||||
function getAvalaibleMediaServerName() {
|
function getAvailableMediaServerName() {
|
||||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||||
}
|
}
|
||||||
@@ -334,7 +334,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
|
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvalaible4kMediaServerName() {
|
function getAvailable4kMediaServerName() {
|
||||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||||
}
|
}
|
||||||
@@ -547,7 +547,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
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"
|
||||||
@@ -632,47 +632,48 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
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="tv"
|
mediaType="tv"
|
||||||
onUpdate={() => revalidate()}
|
onUpdate={() => revalidate()}
|
||||||
|
|||||||
@@ -210,7 +210,9 @@ const UserList = () => {
|
|||||||
username: Yup.string().required(
|
username: Yup.string().required(
|
||||||
intl.formatMessage(messages.validationUsername)
|
intl.formatMessage(messages.validationUsername)
|
||||||
),
|
),
|
||||||
email: Yup.string().email(intl.formatMessage(messages.validationEmail)),
|
email: Yup.string()
|
||||||
|
.required()
|
||||||
|
.email(intl.formatMessage(messages.validationEmail)),
|
||||||
password: Yup.lazy((value) =>
|
password: Yup.lazy((value) =>
|
||||||
!value
|
!value
|
||||||
? Yup.string()
|
? Yup.string()
|
||||||
@@ -388,6 +390,7 @@ const UserList = () => {
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="email" className="text-label">
|
<label htmlFor="email" className="text-label">
|
||||||
{intl.formatMessage(messages.email)}
|
{intl.formatMessage(messages.email)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
@@ -396,6 +399,11 @@ const UserList = () => {
|
|||||||
name="email"
|
name="email"
|
||||||
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.email &&
|
{errors.email &&
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ const UserGeneralSettings = () => {
|
|||||||
|
|
||||||
const UserGeneralSettingsSchema = Yup.object().shape({
|
const UserGeneralSettingsSchema = Yup.object().shape({
|
||||||
email:
|
email:
|
||||||
user?.id === 1
|
// email is required for everybody except non-admin jellyfin users
|
||||||
|
user?.id === 1 ||
|
||||||
|
(user?.userType !== UserType.JELLYFIN && user?.userType !== UserType.EMBY)
|
||||||
? Yup.string()
|
? Yup.string()
|
||||||
.email(intl.formatMessage(messages.validationemailformat))
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
.required(intl.formatMessage(messages.validationemailrequired))
|
.required(intl.formatMessage(messages.validationemailrequired))
|
||||||
@@ -413,7 +415,7 @@ const UserGeneralSettings = () => {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field relative z-30">
|
||||||
<RegionSelector
|
<RegionSelector
|
||||||
name="discoverRegion"
|
name="discoverRegion"
|
||||||
value={values.discoverRegion ?? ''}
|
value={values.discoverRegion ?? ''}
|
||||||
@@ -449,7 +451,7 @@ const UserGeneralSettings = () => {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field relative z-20">
|
||||||
<RegionSelector
|
<RegionSelector
|
||||||
name="streamingRegion"
|
name="streamingRegion"
|
||||||
value={values.streamingRegion || ''}
|
value={values.streamingRegion || ''}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import Alert from '@app/components/Common/Alert';
|
||||||
|
import Modal from '@app/components/Common/Modal';
|
||||||
|
import useSettings from '@app/hooks/useSettings';
|
||||||
|
import { useUser } from '@app/hooks/useUser';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { Transition } from '@headlessui/react';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
const messages = defineMessages(
|
||||||
|
'components.UserProfile.UserSettings.LinkJellyfinModal',
|
||||||
|
{
|
||||||
|
title: 'Link {mediaServerName} Account',
|
||||||
|
description:
|
||||||
|
'Enter your {mediaServerName} credentials to link your account with {applicationName}.',
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
usernameRequired: 'You must provide a username',
|
||||||
|
passwordRequired: 'You must provide a password',
|
||||||
|
saving: 'Adding…',
|
||||||
|
save: 'Link',
|
||||||
|
errorUnauthorized:
|
||||||
|
'Unable to connect to {mediaServerName} using your credentials',
|
||||||
|
errorExists: 'This account is already linked to a {applicationName} user',
|
||||||
|
errorUnknown: 'An unknown error occurred',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
interface LinkJellyfinModalProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const settings = useSettings();
|
||||||
|
const { user } = useUser();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const JellyfinLoginSchema = Yup.object().shape({
|
||||||
|
username: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.usernameRequired)
|
||||||
|
),
|
||||||
|
password: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.passwordRequired)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const applicationName = settings.currentSettings.applicationTitle;
|
||||||
|
const mediaServerName =
|
||||||
|
settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||||
|
? 'Emby'
|
||||||
|
: 'Jellyfin';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
appear
|
||||||
|
show={show}
|
||||||
|
enter="transition ease-in-out duration-300 transform opacity-0"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacuty-100"
|
||||||
|
leave="transition ease-in-out duration-300 transform opacity-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
}}
|
||||||
|
validationSchema={JellyfinLoginSchema}
|
||||||
|
onSubmit={async ({ username, password }) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
setError(
|
||||||
|
intl.formatMessage(messages.errorUnauthorized, {
|
||||||
|
mediaServerName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (res.status === 422) {
|
||||||
|
setError(
|
||||||
|
intl.formatMessage(messages.errorExists, { applicationName })
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(intl.formatMessage(messages.errorUnknown));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onSave();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(intl.formatMessage(messages.errorUnknown));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onCancel={() => {
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
okButtonType="primary"
|
||||||
|
okButtonProps={{ type: 'submit', form: 'link-jellyfin-account' }}
|
||||||
|
okText={
|
||||||
|
isSubmitting
|
||||||
|
? intl.formatMessage(messages.saving)
|
||||||
|
: intl.formatMessage(messages.save)
|
||||||
|
}
|
||||||
|
okDisabled={isSubmitting || !isValid}
|
||||||
|
onOk={() => handleSubmit()}
|
||||||
|
title={intl.formatMessage(messages.title, { mediaServerName })}
|
||||||
|
dialogClass="sm:max-w-lg"
|
||||||
|
>
|
||||||
|
<Form id="link-jellyfin-account">
|
||||||
|
{intl.formatMessage(messages.description, {
|
||||||
|
mediaServerName,
|
||||||
|
applicationName,
|
||||||
|
})}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Alert type="error">{error}</Alert>
|
||||||
|
</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="flex rounded-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>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkJellyfinModal;
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
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 Alert from '@app/components/Common/Alert';
|
||||||
|
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||||
|
import Dropdown from '@app/components/Common/Dropdown';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import useSettings from '@app/hooks/useSettings';
|
||||||
|
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import PlexOAuth from '@app/utils/plex';
|
||||||
|
import { TrashIcon } from '@heroicons/react/24/solid';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import LinkJellyfinModal from './LinkJellyfinModal';
|
||||||
|
|
||||||
|
const messages = defineMessages(
|
||||||
|
'components.UserProfile.UserSettings.UserLinkedAccountsSettings',
|
||||||
|
{
|
||||||
|
linkedAccounts: 'Linked Accounts',
|
||||||
|
linkedAccountsHint:
|
||||||
|
'These external accounts are linked to your {applicationName} account.',
|
||||||
|
noLinkedAccounts:
|
||||||
|
'You do not have any external accounts linked to your account.',
|
||||||
|
noPermissionDescription:
|
||||||
|
"You do not have permission to modify this user's linked accounts.",
|
||||||
|
plexErrorUnauthorized: 'Unable to connect to Plex using your credentials',
|
||||||
|
plexErrorExists: 'This account is already linked to a Plex user',
|
||||||
|
errorUnknown: 'An unknown error occurred',
|
||||||
|
deleteFailed: 'Unable to delete linked account.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const plexOAuth = new PlexOAuth();
|
||||||
|
|
||||||
|
enum LinkedAccountType {
|
||||||
|
Plex = 'Plex',
|
||||||
|
Jellyfin = 'Jellyfin',
|
||||||
|
Emby = 'Emby',
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkedAccount = {
|
||||||
|
type: LinkedAccountType;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserLinkedAccountsSettings = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const settings = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user: currentUser } = useUser();
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
hasPermission,
|
||||||
|
revalidate: revalidateUser,
|
||||||
|
} = useUser({ id: Number(router.query.userId) });
|
||||||
|
const { data: passwordInfo } = useSWR<{ hasPassword: boolean }>(
|
||||||
|
user ? `/api/v1/user/${user?.id}/settings/password` : null
|
||||||
|
);
|
||||||
|
const [showJellyfinModal, setShowJellyfinModal] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const applicationName = settings.currentSettings.applicationTitle;
|
||||||
|
|
||||||
|
const accounts: LinkedAccount[] = useMemo(() => {
|
||||||
|
const accounts: LinkedAccount[] = [];
|
||||||
|
if (!user) return accounts;
|
||||||
|
if (user.userType === UserType.PLEX && user.plexUsername)
|
||||||
|
accounts.push({
|
||||||
|
type: LinkedAccountType.Plex,
|
||||||
|
username: user.plexUsername,
|
||||||
|
});
|
||||||
|
if (user.userType === UserType.EMBY && user.jellyfinUsername)
|
||||||
|
accounts.push({
|
||||||
|
type: LinkedAccountType.Emby,
|
||||||
|
username: user.jellyfinUsername,
|
||||||
|
});
|
||||||
|
if (user.userType === UserType.JELLYFIN && user.jellyfinUsername)
|
||||||
|
accounts.push({
|
||||||
|
type: LinkedAccountType.Jellyfin,
|
||||||
|
username: user.jellyfinUsername,
|
||||||
|
});
|
||||||
|
return accounts;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const linkPlexAccount = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const authToken = await plexOAuth.login();
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/user/${user?.id}/settings/linked-accounts/plex`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ authToken }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
setError(intl.formatMessage(messages.plexErrorUnauthorized));
|
||||||
|
} else if (res.status === 422) {
|
||||||
|
setError(intl.formatMessage(messages.plexErrorExists));
|
||||||
|
} else {
|
||||||
|
setError(intl.formatMessage(messages.errorUnknown));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await revalidateUser();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(intl.formatMessage(messages.errorUnknown));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkable = [
|
||||||
|
{
|
||||||
|
name: 'Plex',
|
||||||
|
action: () => {
|
||||||
|
plexOAuth.preparePopup();
|
||||||
|
setTimeout(() => linkPlexAccount(), 1500);
|
||||||
|
},
|
||||||
|
hide:
|
||||||
|
settings.currentSettings.mediaServerType !== MediaServerType.PLEX ||
|
||||||
|
accounts.some((a) => a.type === LinkedAccountType.Plex),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Jellyfin',
|
||||||
|
action: () => setShowJellyfinModal(true),
|
||||||
|
hide:
|
||||||
|
settings.currentSettings.mediaServerType !== MediaServerType.JELLYFIN ||
|
||||||
|
accounts.some((a) => a.type === LinkedAccountType.Jellyfin),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Emby',
|
||||||
|
action: () => setShowJellyfinModal(true),
|
||||||
|
hide:
|
||||||
|
settings.currentSettings.mediaServerType !== MediaServerType.EMBY ||
|
||||||
|
accounts.some((a) => a.type === LinkedAccountType.Emby),
|
||||||
|
},
|
||||||
|
].filter((l) => !l.hide);
|
||||||
|
|
||||||
|
const deleteRequest = async (account: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/user/${user?.id}/settings/linked-accounts/${account}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
} catch {
|
||||||
|
setError(intl.formatMessage(messages.deleteFailed));
|
||||||
|
}
|
||||||
|
|
||||||
|
await revalidateUser();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentUser?.id !== user?.id &&
|
||||||
|
hasPermission(Permission.ADMIN) &&
|
||||||
|
currentUser?.id !== 1
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="heading">
|
||||||
|
{intl.formatMessage(messages.linkedAccounts)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Alert
|
||||||
|
title={intl.formatMessage(messages.noPermissionDescription)}
|
||||||
|
type="error"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableMediaServerUnlink = user?.id !== 1 && passwordInfo?.hasPassword;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
title={[
|
||||||
|
intl.formatMessage(messages.linkedAccounts),
|
||||||
|
intl.formatMessage(globalMessages.usersettings),
|
||||||
|
user?.displayName,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="mb-6 flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="heading">
|
||||||
|
{intl.formatMessage(messages.linkedAccounts)}
|
||||||
|
</h3>
|
||||||
|
<h6 className="description">
|
||||||
|
{intl.formatMessage(messages.linkedAccountsHint, {
|
||||||
|
applicationName,
|
||||||
|
})}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
{currentUser?.id === user?.id && !!linkable.length && (
|
||||||
|
<div>
|
||||||
|
<Dropdown text="Link Account" buttonType="ghost">
|
||||||
|
{linkable.map(({ name, action }) => (
|
||||||
|
<Dropdown.Item key={name} onClick={action}>
|
||||||
|
{name}
|
||||||
|
</Dropdown.Item>
|
||||||
|
))}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <Alert title={error} type="error" />}
|
||||||
|
{accounts.length ? (
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{accounts.map((acct, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-4 overflow-hidden rounded-lg bg-gray-800 bg-opacity-50 px-4 py-5 shadow ring-1 ring-gray-700 sm:p-6"
|
||||||
|
>
|
||||||
|
<div className="w-12">
|
||||||
|
{acct.type === LinkedAccountType.Plex ? (
|
||||||
|
<div className="flex aspect-square h-full items-center justify-center rounded-full bg-neutral-800">
|
||||||
|
<PlexLogo className="w-9" />
|
||||||
|
</div>
|
||||||
|
) : acct.type === LinkedAccountType.Emby ? (
|
||||||
|
<EmbyLogo />
|
||||||
|
) : (
|
||||||
|
<JellyfinLogo />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="truncate text-sm font-bold text-gray-300">
|
||||||
|
{acct.type}
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-semibold text-white">
|
||||||
|
{acct.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow" />
|
||||||
|
{enableMediaServerUnlink && (
|
||||||
|
<ConfirmButton
|
||||||
|
onClick={() => {
|
||||||
|
deleteRequest(
|
||||||
|
acct.type === LinkedAccountType.Plex ? 'plex' : 'jellyfin'
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
<span>{intl.formatMessage(globalMessages.delete)}</span>
|
||||||
|
</ConfirmButton>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 text-center md:py-12">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-400">
|
||||||
|
{intl.formatMessage(messages.noLinkedAccounts)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LinkJellyfinModal
|
||||||
|
show={showJellyfinModal}
|
||||||
|
onClose={() => setShowJellyfinModal(false)}
|
||||||
|
onSave={() => {
|
||||||
|
setShowJellyfinModal(false);
|
||||||
|
revalidateUser();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserLinkedAccountsSettings;
|
||||||
@@ -18,6 +18,7 @@ import useSWR from 'swr';
|
|||||||
const messages = defineMessages('components.UserProfile.UserSettings', {
|
const messages = defineMessages('components.UserProfile.UserSettings', {
|
||||||
menuGeneralSettings: 'General',
|
menuGeneralSettings: 'General',
|
||||||
menuChangePass: 'Password',
|
menuChangePass: 'Password',
|
||||||
|
menuLinkedAccounts: 'Linked Accounts',
|
||||||
menuNotifications: 'Notifications',
|
menuNotifications: 'Notifications',
|
||||||
menuPermissions: 'Permissions',
|
menuPermissions: 'Permissions',
|
||||||
unauthorizedDescription:
|
unauthorizedDescription:
|
||||||
@@ -63,6 +64,11 @@ const UserSettings = ({ children }: UserSettingsProps) => {
|
|||||||
currentUser?.id !== user?.id &&
|
currentUser?.id !== user?.id &&
|
||||||
hasPermission(Permission.ADMIN, user?.permissions ?? 0)),
|
hasPermission(Permission.ADMIN, user?.permissions ?? 0)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.menuLinkedAccounts),
|
||||||
|
route: '/settings/linked-accounts',
|
||||||
|
regex: /\/settings\/linked-accounts/,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: intl.formatMessage(messages.menuNotifications),
|
text: intl.formatMessage(messages.menuNotifications),
|
||||||
route: data?.emailEnabled
|
route: data?.emailEnabled
|
||||||
|
|||||||