Compare commits

..

3 Commits

Author SHA1 Message Date
Michael Thomas
321865f916 test: update cypress login command 2025-02-16 10:33:33 -05:00
Michael Thomas
1b84c89300 feat: revamp login screen
Update the login screen for better usability, especially with OpenID
Connect and Plex login, allowing one-click login and removing the
accordion layout. Additionally, ensures that media server login is
hidden when disabled in the settings.
2025-02-16 10:33:33 -05:00
Michael Thomas
3f6e5cc84a feat: support disabling jellyfin login 2025-02-16 10:33:33 -05:00
106 changed files with 1488 additions and 4306 deletions

View File

@@ -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>",
"contributorsPerLine": 7,
"projectName": "jellyseerr",
"projectOwner": "fallenbagel",
"projectOwner": "Fallenbagel",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true,
@@ -94,8 +94,7 @@
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
"profile": "https://github.com/jab416171",
"contributions": [
"doc",
"code"
"doc"
]
},
{
@@ -339,8 +338,7 @@
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
"profile": "https://gauthierth.fr/",
"contributions": [
"code",
"maintenance"
"code"
]
},
{
@@ -603,105 +601,6 @@
"contributions": [
"design"
]
},
{
"login": "ishanjain28",
"name": "Ishan Jain",
"avatar_url": "https://avatars.githubusercontent.com/u/7921368?v=4",
"profile": "https://ishanjain.me",
"contributions": [
"code"
]
},
{
"login": "michaelhthomas",
"name": "Michael Thomas",
"avatar_url": "https://avatars.githubusercontent.com/u/18223295?v=4",
"profile": "http://michaelt.xyz",
"contributions": [
"code"
]
},
{
"login": "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"
]
}
]
}

View File

@@ -97,7 +97,7 @@ When adding new UI text, please try to adhere to the following guidelines:
## Translation
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).
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).
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>

106
README.md
View File

@@ -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="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 -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-67-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-65-orange.svg"/></a>
<!-- 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/)**.
@@ -86,91 +86,89 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<table>
<tbody>
<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/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/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://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/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/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://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://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>
<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://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/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://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://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://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://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>
<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/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://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/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/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/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>
<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/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="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/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="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>
</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/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="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://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="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://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>
<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://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://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/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://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://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/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/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/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="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>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
</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://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/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="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/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
<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="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>
</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/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>
</tr>
</tbody>
</table>
@@ -288,7 +286,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/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/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/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/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>
</tr>
@@ -321,8 +319,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
</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/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>
</tbody>
</table>

View File

@@ -4,7 +4,7 @@
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
"main": {
"apiKey": "testkey",
"applicationTitle": "Jellyseerr",
"applicationTitle": "Overseerr",
"applicationUrl": "",
"csrfProtection": false,
"cacheImages": false,
@@ -19,8 +19,6 @@
"discoverRegion": "",
"streamingRegion": "",
"originalLanguage": "",
"blacklistedTags": "",
"blacklistedTagsLimit": 50,
"trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true,
@@ -73,7 +71,7 @@
"ignoreTls": false,
"requireTls": false,
"allowSelfSigned": false,
"senderName": "Jellyseerr"
"senderName": "Overseerr"
}
},
"discord": {

View File

@@ -13,10 +13,10 @@ describe('General Settings', () => {
});
it('modifies setting that requires restart', () => {
cy.visit('/settings/network');
cy.visit('/settings');
cy.get('#trustProxy').click();
cy.get('[data-testid=settings-network-form]').submit();
cy.get('[data-testid=settings-main-form]').submit();
cy.get('[data-testid=modal-title]').should(
'contain',
'Server Restart Required'
@@ -26,7 +26,7 @@ describe('General Settings', () => {
cy.get('[data-testid=modal-title]').should('not.exist');
cy.get('[type=checkbox]#trustProxy').click();
cy.get('[data-testid=settings-network-form]').submit();
cy.get('[data-testid=settings-main-form]').submit();
cy.get('[data-testid=modal-title]').should('not.exist');
});
});

View File

@@ -62,14 +62,6 @@ Set the default display language for Jellyseerr. Users can override this setting
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
## Blacklist Content with Tags and Limit Content Blacklisted per Tag
These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs.
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage.
Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
## Hide Available Media
When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.

View File

@@ -1,19 +1,19 @@
openapi: '3.0.2'
info:
title: 'Jellyseerr API'
title: 'Overseerr API'
version: '1.0.0'
description: |
This is the documentation for the Jellyseerr API backend.
This is the documentation for the Overseerr API backend.
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.
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Jellyseerr.
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Overseerr.
tags:
- name: public
description: Public API endpoints requiring no authentication.
- name: settings
description: Endpoints related to Jellyseerr's settings and configuration.
description: Endpoints related to Overseerr's settings and configuration.
- name: auth
description: Endpoints related to logging in or out, and the currently authenticated user.
- name: users
@@ -160,10 +160,16 @@ components:
example: en
applicationTitle:
type: string
example: Jellyseerr
example: Overseerr
applicationUrl:
type: string
example: https://os.example.com
trustProxy:
type: boolean
example: true
csrfProtection:
type: boolean
example: false
hideAvailable:
type: boolean
example: false
@@ -185,21 +191,12 @@ components:
enableSpecialEpisodes:
type: boolean
example: false
NetworkSettings:
type: object
properties:
csrfProtection:
type: boolean
example: false
forceIpv4First:
type: boolean
example: false
dnsServers:
type: string
example: '1.1.1.1'
trustProxy:
type: boolean
example: true
PlexLibrary:
type: object
properties:
@@ -1438,7 +1435,7 @@ components:
example: no-reply@example.com
senderName:
type: string
example: Jellyseerr
example: Overseerr
smtpHost:
type: string
example: 127.0.0.1
@@ -1969,8 +1966,8 @@ components:
paths:
/status:
get:
summary: Get Jellyseerr status
description: Returns the current Jellyseerr status in a JSON object.
summary: Get Overseerr status
description: Returns the current Overseerr status in a JSON object.
security: []
tags:
- public
@@ -2048,37 +2045,6 @@ paths:
application/json:
schema:
$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:
post:
summary: Get main settings with newly-generated API key
@@ -4156,12 +4122,6 @@ paths:
type: string
nullable: true
example: dune
- in: query
name: filter
schema:
type: string
enum: [all, manual, blacklistedTags]
default: manual
responses:
'200':
description: Blacklisted items returned
@@ -4429,104 +4389,6 @@ paths:
responses:
'204':
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:
get:
summary: Get notification settings for a user

View File

@@ -5,7 +5,7 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"postinstall": "node postinstall-win.js",
"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",
"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",
"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": "pnpm build:next && pnpm build:server",

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -3,7 +3,7 @@
// previously cached resources to be updated from the network.
// This variable is intentionally declared and unused.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const OFFLINE_VERSION = 4;
const OFFLINE_VERSION = 3;
const CACHE_NAME = 'offline';
// Customize this with a different URL if needed.
const OFFLINE_URL = '/offline.html';
@@ -107,25 +107,6 @@ 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));
});

View File

@@ -72,7 +72,7 @@ class GithubAPI extends ExternalAPI {
);
}
public async getJellyseerrReleases({
public async getOverseerrReleases({
take = 20,
}: {
take?: number;
@@ -88,14 +88,14 @@ class GithubAPI extends ExternalAPI {
return data;
} catch (e) {
logger.warn(
"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.",
"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.",
{ label: 'GitHub API', errorMessage: e.message }
);
return [];
}
}
public async getJellyseerrCommits({
public async getOverseerrCommits({
take = 20,
branch = 'develop',
}: {
@@ -114,7 +114,7 @@ class GithubAPI extends ExternalAPI {
return data;
} catch (e) {
logger.warn(
"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.",
"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.",
{ label: 'GitHub API', errorMessage: e.message }
);
return [];

View File

@@ -95,11 +95,7 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
class JellyfinAPI extends ExternalAPI {
private userId?: string;
constructor(
jellyfinHost: string,
authToken?: string | null,
deviceId?: string | null
) {
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
let authHeaderVal: string;
if (authToken) {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;

View File

@@ -92,7 +92,7 @@ class PlexAPI {
plexSettings,
timeout,
}: {
plexToken?: string | null;
plexToken?: string;
plexSettings?: PlexSettings;
timeout?: number;
}) {
@@ -107,7 +107,7 @@ class PlexAPI {
port: settingsPlex.port,
https: settingsPlex.useSsl,
timeout: timeout,
token: plexToken ?? undefined,
token: plexToken,
authenticator: {
authenticate: (
_plexApi,
@@ -124,9 +124,9 @@ class PlexAPI {
// },
options: {
identifier: settings.clientId,
product: 'Jellyseerr',
deviceName: 'Jellyseerr',
platform: 'Jellyseerr',
product: 'Overseerr',
deviceName: 'Overseerr',
platform: 'Overseerr',
},
});
}

View File

@@ -37,26 +37,23 @@ interface SingleSearchOptions extends SearchOptions {
year?: number;
}
export const SortOptionsIterable = [
'popularity.desc',
'popularity.asc',
'release_date.desc',
'release_date.asc',
'revenue.desc',
'revenue.asc',
'primary_release_date.desc',
'primary_release_date.asc',
'original_title.asc',
'original_title.desc',
'vote_average.desc',
'vote_average.asc',
'vote_count.desc',
'vote_count.asc',
'first_air_date.desc',
'first_air_date.asc',
] as const;
export type SortOptions = (typeof SortOptionsIterable)[number];
export type SortOptions =
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
interface DiscoverMovieOptions {
page?: number;
@@ -259,7 +256,6 @@ class TheMovieDb extends ExternalAPI {
language,
append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
include_video_language: language + ', en',
},
43200
);
@@ -284,7 +280,6 @@ class TheMovieDb extends ExternalAPI {
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
include_video_language: language + ', en',
},
43200
);

View File

@@ -7,6 +7,5 @@ export enum ApiErrorCode {
NoAdminUser = 'NO_ADMIN_USER',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unauthorized = 'UNAUTHORIZED',
Unknown = 'UNKNOWN',
}

View File

@@ -1,9 +1,8 @@
import { MediaStatus, type MediaType } from '@server/constants/media';
import dataSource from '@server/datasource';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
import type { EntityManager } from 'typeorm';
import {
Column,
CreateDateColumn,
@@ -36,7 +35,7 @@ export class Blacklist implements BlacklistItem {
@ManyToOne(() => User, (user) => user.id, {
eager: true,
})
user?: User;
user: User;
@OneToOne(() => Media, (media) => media.blacklist, {
onDelete: 'CASCADE',
@@ -44,9 +43,6 @@ export class Blacklist implements BlacklistItem {
@JoinColumn()
public media: Media;
@Column({ nullable: true, type: 'varchar' })
public blacklistedTags?: string;
@CreateDateColumn()
public createdAt: Date;
@@ -54,32 +50,27 @@ export class Blacklist implements BlacklistItem {
Object.assign(this, init);
}
public static async addToBlacklist(
{
blacklistRequest,
}: {
blacklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
blacklistedTags?: string;
};
},
entityManager?: EntityManager
): Promise<void> {
const em = entityManager ?? dataSource;
public static async addToBlacklist({
blacklistRequest,
}: {
blacklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
};
}): Promise<void> {
const blacklist = new this({
...blacklistRequest,
});
const mediaRepository = em.getRepository(Media);
const mediaRepository = getRepository(Media);
let media = await mediaRepository.findOne({
where: {
tmdbId: blacklistRequest.tmdbId,
},
});
const blacklistRepository = em.getRepository(this);
const blacklistRepository = getRepository(this);
await blacklistRepository.save(blacklist);

View File

@@ -734,11 +734,8 @@ export class MediaRequest {
media.mediaType === MediaType.MOVIE &&
this.status === MediaRequestStatus.DECLINED
) {
const statusField = this.is4k ? 'status4k' : 'status';
await mediaRepository.update(
{ id: this.media.id },
{ [statusField]: MediaStatus.UNKNOWN }
);
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
/**
@@ -755,11 +752,8 @@ export class MediaRequest {
).length === 0 &&
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
) {
const statusField = this.is4k ? 'status4k' : 'status';
mediaRepository.update(
{ id: this.media.id },
{ [statusField]: MediaStatus.UNKNOWN }
);
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
// Approve child seasons if parent is approved
@@ -961,10 +955,8 @@ export class MediaRequest {
});
const requestRepository = getRepository(MediaRequest);
await requestRepository.update(this.id, {
status: MediaRequestStatus.APPROVED,
});
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
}
@@ -994,22 +986,18 @@ export class MediaRequest {
throw new Error('Media data not found');
}
const updateFields = {
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
radarrMovie.id,
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
radarrMovie.titleSlug,
[this.is4k ? 'serviceId4k' : 'serviceId']: radarrMovie?.id,
};
await mediaRepository.update({ id: this.media.id }, updateFields);
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
radarrMovie.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
radarrMovie.titleSlug;
media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id;
await mediaRepository.save(media);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
await requestRepository.update(this.id, {
status: MediaRequestStatus.FAILED,
});
this.status = MediaRequestStatus.FAILED;
await requestRepository.save(this);
logger.warn(
'Something went wrong sending movie request to Radarr, marking status as FAILED',
@@ -1125,9 +1113,8 @@ export class MediaRequest {
});
const requestRepository = getRepository(MediaRequest);
await requestRepository.update(this.id, {
status: MediaRequestStatus.APPROVED,
});
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
}

View File

@@ -56,11 +56,11 @@ export class User {
})
public email: string;
@Column({ type: 'varchar', nullable: true })
public plexUsername?: string | null;
@Column({ nullable: true })
public plexUsername?: string;
@Column({ type: 'varchar', nullable: true })
public jellyfinUsername?: string | null;
@Column({ nullable: true })
public jellyfinUsername?: string;
@Column({ nullable: true })
public username?: string;
@@ -77,20 +77,20 @@ export class User {
@Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType;
@Column({ type: 'integer', nullable: true, select: true })
public plexId?: number | null;
@Column({ nullable: true, select: true })
public plexId?: number;
@Column({ type: 'varchar', nullable: true })
public jellyfinUserId?: string | null;
@Column({ nullable: true })
public jellyfinUserId?: string;
@Column({ type: 'varchar', nullable: true, select: false })
public jellyfinDeviceId?: string | null;
@Column({ nullable: true, select: false })
public jellyfinDeviceId?: string;
@Column({ type: 'varchar', nullable: true, select: false })
public jellyfinAuthToken?: string | null;
@Column({ nullable: true, select: false })
public jellyfinAuthToken?: string;
@Column({ type: 'varchar', nullable: true, select: false })
public plexToken?: string | null;
@Column({ nullable: true, select: false })
public plexToken?: string;
@Column({ type: 'integer', default: 0 })
public permissions = 0;

View File

@@ -41,9 +41,9 @@ import path from 'path';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
const API_SPEC_PATH = path.join(__dirname, '../jellyseerr-api.yml');
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Jellyseerr version ${getAppVersion()}`);
logger.info(`Starting Overseerr version ${getAppVersion()}`);
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
@@ -72,26 +72,23 @@ app
// Load Settings
const settings = await getSettings().load();
restartFlag.initializeSettings(settings);
restartFlag.initializeSettings(settings.main);
// Check if we force IPv4 first
if (
process.env.forceIpv4First === 'true' ||
settings.network.forceIpv4First
) {
if (process.env.forceIpv4First === 'true' || settings.main.forceIpv4First) {
dns.setDefaultResultOrder('ipv4first');
net.setDefaultAutoSelectFamily(false);
}
if (settings.network.dnsServers.trim() !== '') {
if (settings.main.dnsServers.trim() !== '') {
dns.setServers(
settings.network.dnsServers.split(',').map((server) => server.trim())
settings.main.dnsServers.split(',').map((server) => server.trim())
);
}
// Register HTTP proxy
if (settings.network.proxy.enabled) {
await createCustomProxyAgent(settings.network.proxy);
if (settings.main.proxy.enabled) {
await createCustomProxyAgent(settings.main.proxy);
}
// Migrate library types
@@ -146,7 +143,7 @@ app
await DiscoverSlider.bootstrapSliders();
const server = express();
if (settings.network.trustProxy) {
if (settings.main.trustProxy) {
server.enable('trust proxy');
}
server.use(cookieParser());
@@ -167,7 +164,7 @@ app
next();
}
});
if (settings.network.csrfProtection) {
if (settings.main.csrfProtection) {
server.use(
csurf({
cookie: {
@@ -197,7 +194,7 @@ app
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: settings.network.csrfProtection ? 'strict' : 'lax',
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
secure: 'auto',
},
store: new TypeormStore({

View File

@@ -6,8 +6,7 @@ export interface BlacklistItem {
mediaType: 'movie' | 'tv';
title?: string;
createdAt?: Date;
user?: User;
blacklistedTags?: string;
user: User;
}
export interface BlacklistResultsResponse extends PaginatedResponse {

View File

@@ -1,184 +0,0 @@
import type { SortOptions } from '@server/api/themoviedb';
import { SortOptionsIterable } from '@server/api/themoviedb';
import type {
TmdbSearchMovieResponse,
TmdbSearchTvResponse,
} from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import dataSource from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media';
import type {
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { createTmdbWithRegionLanguage } from '@server/routes/discover';
import type { EntityManager } from 'typeorm';
const TMDB_API_DELAY_MS = 250;
class AbortTransaction extends Error {}
class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
private running = false;
private progress = 0;
private total = 0;
public async run() {
this.running = true;
try {
await dataSource.transaction(async (em) => {
await this.cleanBlacklist(em);
await this.createBlacklistEntries(em);
});
} catch (err) {
if (err instanceof AbortTransaction) {
logger.info('Aborting job: Process Blacklisted Tags', {
label: 'Jobs',
});
} else {
throw err;
}
} finally {
this.reset();
}
}
public status(): StatusBase {
return {
running: this.running,
progress: this.progress,
total: this.total,
};
}
public cancel() {
this.running = false;
this.progress = 0;
this.total = 0;
}
private reset() {
this.cancel();
}
private async createBlacklistEntries(em: EntityManager) {
const tmdb = createTmdbWithRegionLanguage();
const settings = getSettings();
const blacklistedTags = settings.main.blacklistedTags;
const blacklistedTagsArr = blacklistedTags.split(',');
const pageLimit = settings.main.blacklistedTagsLimit;
if (blacklistedTags.length === 0) {
return;
}
// The maximum number of queries we're expected to execute
this.total =
2 * blacklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
for (const type of [MediaType.MOVIE, MediaType.TV]) {
const getDiscover =
type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv;
// Iterate for each tag
for (const tag of blacklistedTagsArr) {
let queryMax = pageLimit * SortOptionsIterable.length;
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
for (let query = 0; query < queryMax; query++) {
const page: number = fixedSortMode
? query + 1
: (query % pageLimit) + 1;
const sortBy: SortOptions | undefined = fixedSortMode
? undefined
: SortOptionsIterable[query % SortOptionsIterable.length];
if (!this.running) {
throw new AbortTransaction();
}
const response = await getDiscover({
page,
sortBy,
keywords: tag,
});
await this.processResults(response, tag, type, em);
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
this.progress++;
if (page === 1 && response.total_pages <= queryMax) {
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages;
fixedSortMode = true;
queryMax = response.total_pages;
}
}
}
}
}
private async processResults(
response: TmdbSearchMovieResponse | TmdbSearchTvResponse,
keywordId: string,
mediaType: MediaType,
em: EntityManager
) {
const blacklistRepository = em.getRepository(Blacklist);
for (const entry of response.results) {
const blacklistEntry = await blacklistRepository.findOne({
where: { tmdbId: entry.id },
});
if (blacklistEntry) {
// Don't mark manual blacklists with tags
// If media wasn't previously blacklisted for this tag, add the tag to the media's blacklist
if (
blacklistEntry.blacklistedTags &&
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
) {
await blacklistRepository.update(blacklistEntry.id, {
blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`,
});
}
} else {
// Media wasn't previously blacklisted, add it to the blacklist
await Blacklist.addToBlacklist(
{
blacklistRequest: {
mediaType,
title: 'title' in entry ? entry.title : entry.name,
tmdbId: entry.id,
blacklistedTags: `,${keywordId},`,
},
},
em
);
}
}
}
private async cleanBlacklist(em: EntityManager) {
// Remove blacklist and media entries blacklisted by tags
const mediaRepository = em.getRepository(Media);
const mediaToRemove = await mediaRepository
.createQueryBuilder('media')
.innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId')
.where(`blist.blacklistedTags IS NOT NULL`)
.getMany();
// Batch removes so the query doesn't get too large
for (let i = 0; i < mediaToRemove.length; i += 500) {
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading
}
}
}
const blacklistedTagsProcessor = new BlacklistedTagProcessor();
export default blacklistedTagsProcessor;

View File

@@ -1,5 +1,4 @@
import { MediaServerType } from '@server/constants/server';
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
@@ -22,7 +21,7 @@ interface ScheduledJob {
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
running?: () => boolean;
cancelFn?: () => void;
@@ -238,21 +237,5 @@ export const startJobs = (): void => {
}),
});
scheduledJobs.push({
id: 'process-blacklisted-tags',
name: 'Process Blacklisted Tags',
type: 'process',
interval: 'days',
cronSchedule: jobs['process-blacklisted-tags'].schedule,
job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => {
logger.info('Starting scheduled job: Process Blacklisted Tags', {
label: 'Jobs',
});
blacklistedTagsProcessor.run();
}),
running: () => blacklistedTagsProcessor.status().running,
cancelFn: () => blacklistedTagsProcessor.cancel(),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};

View File

@@ -19,8 +19,6 @@ export interface NotificationPayload {
request?: MediaRequest;
issue?: Issue;
comment?: IssueComment;
pendingRequestsCount?: number;
isAdmin?: boolean;
}
export abstract class BaseAgent<T extends NotificationAgentConfig> {

View File

@@ -188,7 +188,7 @@ class SlackAgent
type: 'actions',
elements: [
{
action_id: 'open-in-jellyseerr',
action_id: 'open-in-overseerr',
type: 'button',
url,
text: {

View File

@@ -1,7 +1,6 @@
import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaRequestStatus, MediaType } from '@server/constants/media';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import MediaRequest from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
import type { NotificationAgentConfig } from '@server/lib/settings';
@@ -20,8 +19,6 @@ interface PushNotificationPayload {
actionUrl?: string;
actionUrlTitle?: string;
requestId?: number;
pendingRequestsCount?: number;
isAdmin?: boolean;
}
class WebPushAgent
@@ -132,8 +129,6 @@ class WebPushAgent
requestId: payload.request?.id,
actionUrl,
actionUrlTitle,
pendingRequestsCount: payload.pendingRequestsCount,
isAdmin: payload.isAdmin,
};
}
@@ -157,51 +152,6 @@ class WebPushAgent
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 (
payload.notifyUser &&
// Check if user has webpush notifications enabled and fallback to true if undefined
@@ -219,11 +169,7 @@ class WebPushAgent
pushSubs.push(...notifySubs);
}
if (
payload.notifyAdmin ||
type === Notification.MEDIA_APPROVED ||
type === Notification.MEDIA_DECLINED
) {
if (payload.notifyAdmin) {
const users = await userRepository.find();
const manageUsers = users.filter(
@@ -246,42 +192,7 @@ class WebPushAgent
})
.getMany();
// 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);
}
pushSubs.push(...allSubs);
}
if (mainUser && pushSubs.length > 0) {
@@ -291,10 +202,6 @@ class WebPushAgent
settings.vapidPrivate
);
if (type === Notification.MEDIA_PENDING) {
payload = { ...payload, pendingRequestsCount: pendingRequests.length };
}
const notificationPayload = Buffer.from(
JSON.stringify(this.getNotificationPayload(type, payload)),
'utf-8'
@@ -302,7 +209,39 @@ class WebPushAgent
await Promise.all(
pushSubs.map(async (sub) => {
webPushNotification(sub, notificationPayload);
logger.debug('Sending web push notification', {
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);
}
})
);
}

View File

@@ -115,6 +115,7 @@ export interface MainSettings {
apiKey: string;
applicationTitle: string;
applicationUrl: string;
csrfProtection: boolean;
cacheImages: boolean;
defaultPermissions: number;
defaultQuotas: {
@@ -128,19 +129,13 @@ export interface MainSettings {
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
blacklistedTags: string;
blacklistedTagsLimit: number;
trustProxy: boolean;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
locale: string;
}
export interface NetworkSettings {
csrfProtection: boolean;
forceIpv4First: boolean;
dnsServers: string;
trustProxy: boolean;
locale: string;
proxy: ProxySettings;
}
@@ -305,8 +300,7 @@ export type JobId =
| 'jellyfin-recently-added-scan'
| 'jellyfin-full-scan'
| 'image-cache-cleanup'
| 'availability-sync'
| 'process-blacklisted-tags';
| 'availability-sync';
export interface AllSettings {
clientId: string;
@@ -321,7 +315,6 @@ export interface AllSettings {
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
network: NetworkSettings;
}
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
@@ -340,6 +333,7 @@ class Settings {
apiKey: '',
applicationTitle: 'Jellyseerr',
applicationUrl: '',
csrfProtection: false,
cacheImages: false,
defaultPermissions: Permission.REQUEST,
defaultQuotas: {
@@ -353,12 +347,23 @@ class Settings {
discoverRegion: '',
streamingRegion: '',
originalLanguage: '',
blacklistedTags: '',
blacklistedTagsLimit: 50,
trustProxy: false,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
enableSpecialEpisodes: false,
forceIpv4First: false,
dnsServers: '',
locale: 'en',
proxy: {
enabled: false,
hostname: '',
port: 8080,
useSsl: false,
user: '',
password: '',
bypassFilter: '',
bypassLocalAddresses: true,
},
},
plex: {
name: '',
@@ -510,25 +515,6 @@ class Settings {
'image-cache-cleanup': {
schedule: '0 0 5 * * *',
},
'process-blacklisted-tags': {
schedule: '0 30 1 */7 * *',
},
},
network: {
csrfProtection: false,
trustProxy: false,
forceIpv4First: false,
dnsServers: '',
proxy: {
enabled: false,
hostname: '',
port: 8080,
useSsl: false,
user: '',
password: '',
bypassFilter: '',
bypassLocalAddresses: true,
},
},
};
if (initialSettings) {
@@ -641,14 +627,6 @@ class Settings {
this.data.jobs = data;
}
get network(): NetworkSettings {
return this.data.network;
}
set network(data: NetworkSettings) {
this.data.network = data;
}
get clientId(): string {
return this.data.clientId;
}

View File

@@ -1,33 +0,0 @@
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,
dnsServers: settings.main.dnsServers ?? '',
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.dnsServers;
delete settings.main.proxy;
return newSettings;
};
export default migrateNetworkSettings;

View File

@@ -130,7 +130,7 @@ class WatchlistSync {
switch (e.constructor) {
// During watchlist sync, these errors aren't necessarily
// a problem with Jellyseerr. Since we are auto syncing these constantly, it's
// a problem with Overseerr. Since we are auto syncing these constantly, it's
// possible they are unexpectedly at their quota limit, for example. So we'll
// instead log these as debug messages.
case RequestPermissionError:

View File

@@ -43,14 +43,14 @@ const logger = winston.createLogger({
}),
new winston.transports.DailyRotateFile({
filename: process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/logs/jellyseerr-%DATE%.log`
: path.join(__dirname, '../config/logs/jellyseerr-%DATE%.log'),
? `${process.env.CONFIG_DIRECTORY}/logs/overseerr-%DATE%.log`
: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '7d',
createSymlink: true,
symlinkName: 'jellyseerr.log',
symlinkName: 'overseerr.log',
}),
new winston.transports.DailyRotateFile({
filename: process.env.CONFIG_DIRECTORY

View File

@@ -1,17 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface {
name = 'AddBlacklistTagsColumn1737320080282';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "blacklist" ADD blacklistedTags character varying`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "blacklist" DROP COLUMN blacklistedTags`
);
}
}

View File

@@ -1,34 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface {
name = 'AddBlacklistTagsColumn1737320080282';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"`
);
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
);
await queryRunner.query(
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"))`
);
await queryRunner.query(
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
);
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
}
}

View File

@@ -158,7 +158,7 @@ authRoutes.post('/plex', async (req, res, next) => {
});
} else {
logger.info(
'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user',
'Sign-in attempt from Plex user with access to the media server; creating new Overseerr user',
{
label: 'API',
ip: req.ip,
@@ -274,7 +274,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
if (user) {
deviceId = user.jellyfinDeviceId ?? '';
} else {
deviceId = Buffer.from(`BOT_jellyseerr_${body.username ?? ''}`).toString(
deviceId = Buffer.from(`BOT_overseerr_${body.username ?? ''}`).toString(
'base64'
);
}
@@ -446,7 +446,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
});
} else if (!user) {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating new Jellyseerr user',
'Sign-in attempt from Jellyfin user with access to the media server; creating new Overseerr user',
{
label: 'API',
ip: req.ip,
@@ -584,7 +584,7 @@ authRoutes.post('/local', async (req, res, next) => {
.getOne();
if (!user || !(await user.passwordMatch(body.password))) {
logger.warn('Failed sign-in attempt using invalid Jellyseerr password', {
logger.warn('Failed sign-in attempt using invalid Overseerr password', {
label: 'API',
ip: req.ip,
email: body.email,
@@ -674,7 +674,7 @@ authRoutes.post('/local', async (req, res, next) => {
return res.status(200).json(user?.filter() ?? {});
} catch (e) {
logger.error(
'Something went wrong authenticating with Jellyseerr password',
'Something went wrong authenticating with Overseerr password',
{
label: 'API',
errorMessage: e.message,

View File

@@ -19,54 +19,39 @@ export const blacklistAdd = z.object({
user: z.coerce.number(),
});
const blacklistGet = z.object({
take: z.coerce.number().int().positive().default(25),
skip: z.coerce.number().int().nonnegative().default(0),
search: z.string().optional(),
filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(),
});
blacklistRoutes.get(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
const { take, skip, search, filter } = blacklistGet.parse(req.query);
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const search = (req.query.search as string) ?? '';
try {
let query = getRepository(Blacklist)
.createQueryBuilder('blacklist')
.leftJoinAndSelect('blacklist.user', 'user')
.where('1 = 1'); // Allow use of andWhere later
.leftJoinAndSelect('blacklist.user', 'user');
switch (filter) {
case 'manual':
query = query.andWhere('blacklist.blacklistedTags IS NULL');
break;
case 'blacklistedTags':
query = query.andWhere('blacklist.blacklistedTags IS NOT NULL');
break;
}
if (search) {
query = query.andWhere('blacklist.title like :title', {
if (search.length > 0) {
query = query.where('blacklist.title like :title', {
title: `%${search}%`,
});
}
const [blacklistedItems, itemsCount] = await query
.orderBy('blacklist.createdAt', 'DESC')
.take(take)
.take(pageSize)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(itemsCount / take),
pageSize: take,
pages: Math.ceil(itemsCount / pageSize),
pageSize,
results: itemsCount,
page: Math.ceil(skip / take) + 1,
page: Math.ceil(skip / pageSize) + 1,
},
results: blacklistedItems,
} as BlacklistResultsResponse);

View File

@@ -837,8 +837,7 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
select: ['id', 'plexToken'],
});
if (activeUser && !activeUser?.plexToken) {
// Non-Plex users can only see their own watchlist
if (activeUser) {
const [result, total] = await getRepository(Watchlist).findAndCount({
where: { requestedBy: { id: activeUser?.id } },
relations: {
@@ -867,7 +866,6 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
});
}
// List watchlist from Plex
const plexTV = new PlexTvAPI(activeUser.plexToken);
const watchlist = await plexTV.getWatchlist({ offset });

View File

@@ -55,7 +55,7 @@ router.get<unknown, StatusResponse>('/status', async (req, res) => {
let commitsBehind = 0;
if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
const commits = await githubApi.getJellyseerrCommits();
const commits = await githubApi.getOverseerrCommits();
if (commits.length) {
const filteredCommits = commits.filter(
@@ -74,7 +74,7 @@ router.get<unknown, StatusResponse>('/status', async (req, res) => {
}
}
} else if (commitTag !== 'local') {
const releases = await githubApi.getJellyseerrReleases();
const releases = await githubApi.getOverseerrReleases();
if (releases.length) {
const latestVersion = releases[0];
@@ -403,7 +403,7 @@ router.get('/watchproviders/tv', async (req, res, next) => {
router.get('/', (_req, res) => {
return res.status(200).json({
api: 'Jellyseerr API',
api: 'Overseerr API',
version: '1.0',
});
});

View File

@@ -78,21 +78,6 @@ settingsRoutes.post('/main', async (req, res) => {
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) => {
const settings = getSettings();

View File

@@ -1,7 +1,4 @@
import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
@@ -15,23 +12,9 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import net from 'net';
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 authMiddleware: Middleware = (req, res, next) => {
if (
@@ -119,10 +102,28 @@ userSettingsRoutes.post<
}
const oldEmail = user.email;
const oldUsername = user.username;
user.username = req.body.username;
if (user.userType !== UserType.PLEX) {
if (user.jellyfinUsername) {
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({
where: { email: user.email },
@@ -182,8 +183,9 @@ userSettingsRoutes.post<
status: e.statusCode,
message: e.errorCode,
});
} else {
return next({ status: 500, message: e.message });
}
return next({ status: 500, message: e.message });
}
});
@@ -288,260 +290,6 @@ 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>(
'/notifications',
isOwnProfileOrAdmin(),

View File

@@ -6,7 +6,7 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
export default async function createCustomProxyAgent(
proxySettings: ProxySettings
) {
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
const defaultAgent = new Agent();
const skipUrl = (url: string) => {
const hostname = new URL(url).hostname;
@@ -63,7 +63,6 @@ export default async function createCustomProxyAgent(
interceptors: {
Client: [noProxyInterceptor],
},
keepAliveTimeout: 5000,
});
setGlobalDispatcher(proxyAgent);

View File

@@ -1,25 +1,22 @@
import type { AllSettings, NetworkSettings } from '@server/lib/settings';
import type { MainSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
class RestartFlag {
private networkSettings: NetworkSettings;
private settings: MainSettings;
public initializeSettings(settings: AllSettings): void {
this.networkSettings = {
...settings.network,
proxy: { ...settings.network.proxy },
};
public initializeSettings(settings: MainSettings): void {
this.settings = { ...settings };
}
public isSet(): boolean {
const networkSettings = getSettings().network;
const settings = getSettings().main;
return (
this.networkSettings.csrfProtection !== networkSettings.csrfProtection ||
this.networkSettings.trustProxy !== networkSettings.trustProxy ||
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled ||
this.networkSettings.forceIpv4First !== networkSettings.forceIpv4First ||
this.networkSettings.dnsServers !== networkSettings.dnsServers
this.settings.csrfProtection !== settings.csrfProtection ||
this.settings.trustProxy !== settings.trustProxy ||
this.settings.proxy.enabled !== settings.proxy.enabled ||
this.settings.forceIpv4First !== settings.forceIpv4First ||
this.settings.dnsServers !== settings.dnsServers
);
}
}

View File

@@ -1,43 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 361 157">
<defs>
<style>
.cls-1 {
fill: #fff;
}
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
.cls-2 {
fill: #eaaf20;
}
</style>
</defs>
<!-- Generator: Adobe Illustrator 28.7.1, SVG Export Plug-In . SVG Version: 1.2.0 Build 142) -->
<g>
<g id="Layer_1">
<path id="path4" class="cls-1"
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" />
<path id="path6" class="cls-1"
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" />
<polygon id="polygon8" class="cls-2"
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" />
<polygon id="polygon10" class="cls-1"
points="330.8 73 360.6 31.7 326.2 31.7 313.8 48.9 330.8 73" />
<path id="path12" class="cls-1"
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" />
<path id="path16" class="cls-1"
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" />
<path id="path4-2" data-name="path4" class="cls-1"
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" />
<path id="path6-2" data-name="path6" class="cls-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" />
<polygon id="polygon8-2" data-name="polygon8" class="cls-2"
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" />
<polygon id="polygon10-2" data-name="polygon10" class="cls-1"
points="329.5 72.5 359.4 31.2 324.9 31.2 312.6 48.3 329.5 72.5" />
<path id="path12-2" data-name="path12" class="cls-1"
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" />
<path id="path16-2" data-name="path16" class="cls-1"
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" />
</g>
</g>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="plex-logo"
x="0px"
y="0px"
viewBox="0 0 1000 460.89727"
xml:space="preserve"
sodipodi:docname="plex-logo.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
id="metadata25"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs23">
</defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#111111"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview21"
showgrid="false"
fit-margin-top="0"
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>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -14,7 +14,6 @@ type AirDateBadgeProps = {
const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
const WEEK = 1000 * 60 * 60 * 24 * 8;
const intl = useIntl();
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const dAirDate = new Date(airDate);
const nowDate = new Date();
const alreadyAired = dAirDate.getTime() < nowDate.getTime();
@@ -39,7 +38,7 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone,
timeZone: 'UTC',
})}
</Badge>
{showRelative && (

View File

@@ -1,4 +1,3 @@
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
@@ -15,7 +14,6 @@ import defineMessages from '@app/utils/defineMessages';
import {
ChevronLeftIcon,
ChevronRightIcon,
FunnelIcon,
MagnifyingGlassIcon,
TrashIcon,
} from '@heroicons/react/24/solid';
@@ -43,17 +41,8 @@ const messages = defineMessages('components.Blacklist', {
blacklistdate: 'date',
blacklistedby: '{date} by {user}',
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
filterManual: 'Manual',
filterBlacklistedTags: 'Blacklisted Tags',
showAllBlacklisted: 'Show All Blacklisted Media',
});
enum Filter {
ALL = 'all',
MANUAL = 'manual',
BLACKLISTEDTAGS = 'blacklistedTags',
}
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
@@ -62,7 +51,6 @@ const Blacklist = () => {
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
useDebouncedState('');
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.MANUAL);
const router = useRouter();
const intl = useIntl();
@@ -75,11 +63,9 @@ const Blacklist = () => {
error,
mutate: revalidate,
} = useSWR<BlacklistResultsResponse>(
`/api/v1/blacklist/?take=${currentPageSize}&skip=${
pageIndex * currentPageSize
}&filter=${currentFilter}${
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
}`,
`/api/v1/blacklist/?take=${currentPageSize}
&skip=${pageIndex * currentPageSize}
${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`,
{
refreshInterval: 0,
revalidateOnFocus: false,
@@ -107,52 +93,19 @@ const Blacklist = () => {
return (
<>
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<FunnelIcon className="h-6 w-6" />
</span>
<select
id="filter"
name="filter"
onChange={(e) => {
setCurrentFilter(e.target.value as Filter);
router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}}
value={currentFilter}
className="rounded-r-only"
>
<option value="all">
{intl.formatMessage(globalMessages.all)}
</option>
<option value="manual">
{intl.formatMessage(messages.filterManual)}
</option>
<option value="blacklistedTags">
{intl.formatMessage(messages.filterBlacklistedTags)}
</option>
</select>
</div>
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<MagnifyingGlassIcon className="h-6 w-6" />
</span>
<input
type="text"
className="rounded-r-only"
value={searchFilter}
onChange={(e) => searchItem(e)}
/>
</div>
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<MagnifyingGlassIcon className="h-6 w-6" />
</span>
<input
type="text"
className="rounded-r-only"
value={searchFilter}
onChange={(e) => searchItem(e)}
/>
</div>
</div>
@@ -163,16 +116,6 @@ const Blacklist = () => {
<span className="text-2xl text-gray-400">
{intl.formatMessage(globalMessages.noresults)}
</span>
{currentFilter !== Filter.ALL && (
<div className="mt-4">
<Button
buttonType="primary"
onClick={() => setCurrentFilter(Filter.ALL)}
>
{intl.formatMessage(messages.showAllBlacklisted)}
</Button>
</div>
)}
</div>
) : (
data.results.map((item: BlacklistItem) => {
@@ -355,7 +298,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
src={
title?.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/jellyseerr_poster_not_found.png'
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
@@ -410,7 +353,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
numeric="auto"
/>
),
user: item.user ? (
user: (
<Link href={`/users/${item.user.id}`}>
<span className="group flex items-center truncate">
<CachedImage
@@ -427,14 +370,6 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
</span>
</span>
</Link>
) : item.blacklistedTags ? (
<span className="ml-1">
<BlacklistedTagsBadge data={item} />
</span>
) : (
<span className="ml-1 truncate text-sm font-semibold">
???
</span>
),
})}
</span>

View File

@@ -1,4 +1,3 @@
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
@@ -79,33 +78,22 @@ const BlacklistBlock = ({
<div className="flex items-center justify-between">
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="white mb-1 flex flex-nowrap">
{data.user ? (
<>
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
data.user.id === user?.id
? '/profile'
: `/users/${data.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{data.user.displayName}
</span>
</Link>
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
data.user.id === user?.id
? '/profile'
: `/users/${data.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{data.user.displayName}
</span>
</>
) : data.blacklistedTags ? (
<>
<span className="w-40 truncate md:w-auto">
{intl.formatMessage(messages.blacklistedby)}:&nbsp;
</span>
<BlacklistedTagsBadge data={data} />
</>
) : null}
</Link>
</span>
</div>
</div>
<div className="ml-2 flex flex-shrink-0 flex-wrap">

View File

@@ -1,60 +0,0 @@
import Badge from '@app/components/Common/Badge';
import Tooltip from '@app/components/Common/Tooltip';
import defineMessages from '@app/utils/defineMessages';
import { TagIcon } from '@heroicons/react/20/solid';
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
import type { Keyword } from '@server/models/common';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Settings', {
blacklistedTagsText: 'Blacklisted Tags',
});
interface BlacklistedTagsBadgeProps {
data: BlacklistItem;
}
const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
const [tagNamesBlacklistedFor, setTagNamesBlacklistedFor] =
useState<string>('Loading...');
const intl = useIntl();
useEffect(() => {
if (!data.blacklistedTags) {
return;
}
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
Promise.all(
keywordIds.map(async (keywordId) => {
const res = await fetch(`/api/v1/keyword/${keywordId}`);
if (!res.ok) {
return '';
}
const keyword: Keyword = await res.json();
return keyword.name;
})
).then((keywords) => {
setTagNamesBlacklistedFor(keywords.join(', '));
});
}, [data.blacklistedTags]);
return (
<Tooltip
content={tagNamesBlacklistedFor}
tooltipConfig={{ followCursor: false }}
>
<Badge
badgeType="dark"
className="items-center border border-red-500 !text-red-400"
>
<TagIcon className="mr-1 h-4" />
{intl.formatMessage(messages.blacklistedTagsText)}
</Badge>
</Tooltip>
);
};
export default BlacklistedTagsBadge;

View File

@@ -1,407 +0,0 @@
import Modal from '@app/components/Common/Modal';
import Tooltip from '@app/components/Common/Tooltip';
import CopyButton from '@app/components/Settings/CopyButton';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { ArrowDownIcon } from '@heroicons/react/24/solid';
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
import type { Keyword } from '@server/models/common';
import { useFormikContext } from 'formik';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { useIntl } from 'react-intl';
import type { ClearIndicatorProps, GroupBase, MultiValue } from 'react-select';
import { components } from 'react-select';
import AsyncSelect from 'react-select/async';
const messages = defineMessages('components.Settings', {
copyBlacklistedTags: 'Copied blacklisted tags to clipboard.',
copyBlacklistedTagsTip: 'Copy blacklisted tag configuration',
copyBlacklistedTagsEmpty: 'Nothing to copy',
importBlacklistedTagsTip: 'Import blacklisted tag configuration',
clearBlacklistedTagsConfirm:
'Are you sure you want to clear the blacklisted tags?',
yes: 'Yes',
no: 'No',
searchKeywords: 'Search keywords…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
blacklistedTagImportTitle: 'Import Blacklisted Tag Configuration',
blacklistedTagImportInstructions: 'Paste blacklist tag configuration below.',
valueRequired: 'You must provide a value.',
noSpecialCharacters:
'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.',
invalidKeyword: '{keywordId} is not a TMDB keyword.',
});
type SingleVal = {
label: string;
value: number;
};
type BlacklistedTagsSelectorProps = {
defaultValue?: string;
};
const BlacklistedTagsSelector = ({
defaultValue,
}: BlacklistedTagsSelectorProps) => {
const { setFieldValue } = useFormikContext();
const [value, setValue] = useState<string | undefined>(defaultValue);
const intl = useIntl();
const [selectorValue, setSelectorValue] =
useState<MultiValue<SingleVal> | null>(null);
const update = useCallback(
(value: MultiValue<SingleVal> | null) => {
const strVal = value?.map((v) => v.value).join(',');
setSelectorValue(value);
setValue(strVal);
setFieldValue('blacklistedTags', strVal);
},
[setSelectorValue, setValue, setFieldValue]
);
const copyDisabled = value === null || value?.length === 0;
return (
<>
<ControlledKeywordSelector
value={selectorValue}
onChange={update}
defaultValue={defaultValue}
components={{
DropdownIndicator: undefined,
IndicatorSeparator: undefined,
ClearIndicator: VerifyClearIndicator,
}}
/>
<CopyButton
textToCopy={value ?? ''}
disabled={copyDisabled}
toastMessage={intl.formatMessage(messages.copyBlacklistedTags)}
tooltipContent={intl.formatMessage(
copyDisabled
? messages.copyBlacklistedTagsEmpty
: messages.copyBlacklistedTagsTip
)}
tooltipConfig={{ followCursor: false }}
/>
<BlacklistedTagsImportButton setSelector={update} />
</>
);
};
type BaseSelectorMultiProps = {
defaultValue?: string;
value: MultiValue<SingleVal> | null;
onChange: (value: MultiValue<SingleVal> | null) => void;
components?: Partial<typeof components>;
};
const ControlledKeywordSelector = ({
defaultValue,
onChange,
components,
value,
}: BaseSelectorMultiProps) => {
const intl = useIntl();
useEffect(() => {
const loadDefaultKeywords = async (): Promise<void> => {
if (!defaultValue) {
return;
}
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const res = await fetch(`/api/v1/keyword/${keywordId}`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const keyword: Keyword = await res.json();
return keyword;
})
);
onChange(
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))
);
};
loadDefaultKeywords();
}, [defaultValue, onChange]);
const loadKeywordOptions = async (inputValue: string) => {
const res = await fetch(
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const results: TmdbKeywordSearchResponse = await res.json();
return results.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
return (
<AsyncSelect
key={`keyword-select-blacklistedTags`}
inputId="data"
isMulti
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
inputValue === ''
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
value={value}
loadOptions={loadKeywordOptions}
placeholder={intl.formatMessage(messages.searchKeywords)}
onChange={onChange}
components={components}
/>
);
};
type BlacklistedTagsImportButtonProps = {
setSelector: (value: MultiValue<SingleVal>) => void;
};
const BlacklistedTagsImportButton = ({
setSelector,
}: BlacklistedTagsImportButtonProps) => {
const [show, setShow] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const intl = useIntl();
const onConfirm = useCallback(async () => {
if (formRef.current) {
if (await formRef.current.submitForm()) {
setShow(false);
}
}
}, []);
const onClick = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setShow(true);
}, []);
return (
<>
<Transition
as="div"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}
>
<Modal
title={intl.formatMessage(messages.blacklistedTagImportTitle)}
okText="Confirm"
onOk={onConfirm}
onCancel={() => setShow(false)}
>
<BlacklistedTagImportForm ref={formRef} setSelector={setSelector} />
</Modal>
</Transition>
<Tooltip
content={intl.formatMessage(messages.importBlacklistedTagsTip)}
tooltipConfig={{ followCursor: false }}
>
<button className="input-action" onClick={onClick} type="button">
<ArrowDownIcon />
</button>
</Tooltip>
</>
);
};
type BlacklistedTagImportFormProps = BlacklistedTagsImportButtonProps;
const BlacklistedTagImportForm = forwardRef<
Partial<HTMLFormElement>,
BlacklistedTagImportFormProps
>((props, ref) => {
const { setSelector } = props;
const intl = useIntl();
const [formValue, setFormValue] = useState('');
const [errors, setErrors] = useState<string[]>([]);
useImperativeHandle(ref, () => ({
submitForm: handleSubmit,
formValue,
}));
const validate = async () => {
if (formValue.length === 0) {
setErrors([intl.formatMessage(messages.valueRequired)]);
return false;
}
if (!/^(?:\d+,)*\d+$/.test(formValue)) {
setErrors([intl.formatMessage(messages.noSpecialCharacters)]);
return false;
}
const keywords = await Promise.allSettled(
formValue.split(',').map(async (keywordId) => {
const res = await fetch(`/api/v1/keyword/${keywordId}`);
if (!res.ok) {
throw intl.formatMessage(messages.invalidKeyword, { keywordId });
}
const keyword: Keyword = await res.json();
return {
label: keyword.name,
value: keyword.id,
};
})
);
const failures = keywords.filter(
(res) => res.status === 'rejected'
) as PromiseRejectedResult[];
if (failures.length > 0) {
setErrors(failures.map((failure) => `${failure.reason}`));
return false;
}
setSelector(
(keywords as PromiseFulfilledResult<SingleVal>[]).map(
(keyword) => keyword.value
)
);
setErrors([]);
return true;
};
const handleSubmit = validate;
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="value">
{intl.formatMessage(messages.blacklistedTagImportInstructions)}
</label>
<textarea
id="value"
value={formValue}
onChange={(e) => setFormValue(e.target.value)}
className="h-20"
/>
{errors.length > 0 && (
<div className="error">
{errors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
)}
</div>
</form>
);
});
const VerifyClearIndicator = <
Option,
IsMuti extends boolean,
Group extends GroupBase<Option>
>(
props: ClearIndicatorProps<Option, IsMuti, Group>
) => {
const { clearValue } = props;
const [show, setShow] = useState(false);
const intl = useIntl();
const openForm = useCallback(() => {
setShow(true);
}, [setShow]);
const openFormKey = useCallback(
(event: React.KeyboardEvent) => {
if (show) return;
if (event.key === 'Enter' || event.key === 'Space') {
setShow(true);
}
},
[setShow, show]
);
const acceptForm = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.stopPropagation();
event.preventDefault();
clearValue();
}
},
[clearValue]
);
useEffect(() => {
if (show) {
window.addEventListener('keydown', acceptForm);
}
return () => window.removeEventListener('keydown', acceptForm);
}, [show, acceptForm]);
return (
<>
<button
type="button"
onClick={openForm}
onKeyDown={openFormKey}
className="react-select__indicator react-select__clear-indicator css-1xc3v61-indicatorContainer cursor-pointer"
>
<components.CrossIcon />
</button>
<Transition
as="div"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}
>
<Modal
subTitle={intl.formatMessage(messages.clearBlacklistedTagsConfirm)}
okText={intl.formatMessage(messages.yes)}
cancelText={intl.formatMessage(messages.no)}
onOk={clearValue}
onCancel={() => setShow(false)}
>
<form />{' '}
{/* Form prevents accidentally saving settings when pressing enter */}
</Modal>
</Transition>
</>
);
};
export default BlacklistedTagsSelector;

View File

@@ -233,7 +233,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/jellyseerr_poster_not_found.png'
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"

View File

@@ -1,29 +1,77 @@
import Dropdown from '@app/components/Common/Dropdown';
import useClickOutside from '@app/hooks/useClickOutside';
import { withProperties } from '@app/utils/typeHelpers';
import { Menu } from '@headlessui/react';
import { Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
import type {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
RefObject,
} from 'react';
import { Fragment, useRef, useState } from 'react';
type ButtonWithDropdownProps = {
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
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;
dropdownIcon?: React.ReactNode;
buttonType?: 'primary' | 'ghost';
} & (
| ({ as?: 'button' } & ButtonHTMLAttributes<HTMLButtonElement>)
| ({ as: 'a' } & AnchorHTMLAttributes<HTMLAnchorElement>)
);
}
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
ButtonWithDropdownProps {
as?: 'button';
}
interface AnchorProps
extends AnchorHTMLAttributes<HTMLAnchorElement>,
ButtonWithDropdownProps {
as: 'a';
}
const ButtonWithDropdown = ({
as,
text,
children,
dropdownIcon,
className,
buttonType = 'primary',
...props
}: ButtonWithDropdownProps) => {
}: ButtonProps | AnchorProps) => {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
useClickOutside(buttonRef, () => setIsOpen(false));
const styleClasses = {
mainButtonClasses: 'button-md text-white border',
dropdownSideButtonClasses: 'button-md border',
dropdownClasses: 'button-md',
};
switch (buttonType) {
@@ -31,40 +79,72 @@ const ButtonWithDropdown = ({
styleClasses.mainButtonClasses +=
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
styleClasses.dropdownClasses +=
' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur';
break;
default:
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';
styleClasses.dropdownSideButtonClasses +=
' 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 (
<Menu as="div" className="relative z-10 inline-flex">
<TriggerElement
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}`}
{...(props as Record<string, string>)}
>
{text}
</TriggerElement>
<span className="relative inline-flex h-full rounded-md shadow-sm">
{as === 'a' ? (
<a
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<HTMLAnchorElement>}
{...(props as AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{text}
</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 && (
<span className="relative -ml-px block">
<Menu.Button
<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}`}
aria-label="Expand"
onClick={() => setIsOpen((state) => !state)}
>
{dropdownIcon ? dropdownIcon : <ChevronDownIcon />}
</Menu.Button>
<Dropdown.Items dropdownType={buttonType}>{children}</Dropdown.Items>
</button>
<Transition
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>
)}
</Menu>
</span>
);
};
export default withProperties(ButtonWithDropdown, { Item: Dropdown.Item });
export default withProperties(ButtonWithDropdown, { Item: DropdownItem });

View File

@@ -1,117 +0,0 @@
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,
});

View File

@@ -29,16 +29,11 @@ interface ModalProps {
secondaryDisabled?: boolean;
tertiaryDisabled?: boolean;
tertiaryButtonType?: ButtonType;
okButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
cancelButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
secondaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
tertiaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
disableScrollLock?: boolean;
backgroundClickable?: boolean;
loading?: boolean;
backdrop?: string;
children?: React.ReactNode;
dialogClass?: string;
}
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
@@ -66,11 +61,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
loading = false,
onTertiary,
backdrop,
dialogClass,
okButtonProps,
cancelButtonProps,
secondaryButtonProps,
tertiaryButtonProps,
},
parentRef
) => {
@@ -116,7 +106,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
</div>
</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 ${dialogClass}`}
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"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
@@ -199,7 +189,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
className="ml-3"
disabled={okDisabled}
data-testid="modal-ok-button"
{...okButtonProps}
>
{okText ? okText : 'Ok'}
</Button>
@@ -211,7 +200,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
className="ml-3"
disabled={secondaryDisabled}
data-testid="modal-secondary-button"
{...secondaryButtonProps}
>
{secondaryText}
</Button>
@@ -222,7 +210,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
onClick={onTertiary}
className="ml-3"
disabled={tertiaryDisabled}
{...tertiaryButtonProps}
>
{tertiaryText}
</Button>
@@ -233,7 +220,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
onClick={onCancel}
className="ml-3 sm:ml-0"
data-testid="modal-cancel-button"
{...cancelButtonProps}
>
{cancelText
? cancelText

View File

@@ -25,10 +25,6 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
return (
<>
<Component
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
{...componentProps}
className={`rounded-l-only ${componentProps.className ?? ''}`}
type={

View File

@@ -33,7 +33,7 @@ import { useRouter } from 'next/router';
import { useState } from 'react';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
import * as Yup from 'yup';
const messages = defineMessages('components.IssueDetails', {
@@ -155,7 +155,6 @@ const IssueDetails = () => {
autoDismiss: true,
});
revalidateIssue();
mutate('/api/v1/issue/count');
} catch (e) {
addToast(intl.formatMessage(messages.toaststatusupdatefailed), {
appearance: 'error',
@@ -170,7 +169,6 @@ const IssueDetails = () => {
method: 'DELETE',
});
if (!res.ok) throw new Error();
mutate('/api/v1/issue/count');
addToast(intl.formatMessage(messages.toastissuedeleted), {
appearance: 'success',
@@ -242,7 +240,7 @@ const IssueDetails = () => {
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/jellyseerr_poster_not_found.png'
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"

View File

@@ -142,7 +142,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/jellyseerr_poster_not_found.png'
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"

View File

@@ -15,7 +15,7 @@ import { Field, Formik } from 'formik';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
import * as Yup from 'yup';
const messages = defineMessages('components.IssueModal.CreateIssueModal', {
@@ -138,8 +138,6 @@ const CreateIssueModal = ({
autoDismiss: true,
}
);
mutate('/api/v1/issue/count');
}
if (onCancel) {

View File

@@ -33,7 +33,6 @@ interface LanguageSelectorProps {
setFieldValue: (property: string, value: string) => void;
serverValue?: string;
isUserSettings?: boolean;
isDisabled?: boolean;
}
const LanguageSelector = ({
@@ -41,7 +40,6 @@ const LanguageSelector = ({
setFieldValue,
serverValue,
isUserSettings = false,
isDisabled,
}: LanguageSelectorProps) => {
const intl = useIntl();
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
@@ -98,7 +96,6 @@ const LanguageSelector = ({
<Select<OptionType, true>
options={options}
isMulti
isDisabled={isDisabled}
className="react-select-container"
classNamePrefix="react-select"
value={

View File

@@ -1,4 +1,3 @@
import Badge from '@app/components/Common/Badge';
import { menuMessages } from '@app/components/Layout/Sidebar';
import useClickOutside from '@app/hooks/useClickOutside';
import { Permission, useUser } from '@app/hooks/useUser';
@@ -27,16 +26,9 @@ import {
} from '@heroicons/react/24/solid';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { cloneElement, useEffect, useRef, useState } from 'react';
import { cloneElement, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
interface MobileMenuProps {
pendingRequestsCount: number;
openIssuesCount: number;
revalidateIssueCount: () => void;
revalidateRequestsCount: () => void;
}
interface MenuLink {
href: string;
svgIcon: JSX.Element;
@@ -49,12 +41,7 @@ interface MenuLink {
dataTestId?: string;
}
const MobileMenu = ({
pendingRequestsCount,
openIssuesCount,
revalidateIssueCount,
revalidateRequestsCount,
}: MobileMenuProps) => {
const MobileMenu = () => {
const ref = useRef<HTMLDivElement>(null);
const intl = useIntl();
const [isOpen, setIsOpen] = useState(false);
@@ -152,21 +139,6 @@ const MobileMenu = ({
})
);
useEffect(() => {
if (openIssuesCount) {
revalidateIssueCount();
}
if (pendingRequestsCount) {
revalidateRequestsCount();
}
}, [
revalidateIssueCount,
revalidateRequestsCount,
pendingRequestsCount,
openIssuesCount,
]);
return (
<div className="fixed bottom-0 left-0 right-0 z-50">
<Transition
@@ -187,7 +159,7 @@ const MobileMenu = ({
<Link
key={`mobile-menu-link-${link.href}`}
href={link.href}
className={`flex items-center ${
className={`flex items-center space-x-2 ${
isActive ? 'text-indigo-500' : ''
}`}
onKeyDown={(e) => {
@@ -202,25 +174,7 @@ const MobileMenu = ({
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
className: 'h-5 w-5',
})}
<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>
)}
<span>{link.content}</span>
</Link>
);
})}
@@ -236,7 +190,7 @@ const MobileMenu = ({
<Link
key={`mobile-menu-link-${link.href}`}
href={link.href}
className={`relative flex flex-col items-center space-y-1 ${
className={`flex flex-col items-center space-y-1 ${
isActive ? 'text-indigo-500' : ''
}`}
>
@@ -246,23 +200,6 @@ const MobileMenu = ({
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>
);
})}

View File

@@ -1,4 +1,3 @@
import Badge from '@app/components/Common/Badge';
import UserWarnings from '@app/components/Layout/UserWarnings';
import VersionStatus from '@app/components/Layout/VersionStatus';
import useClickOutside from '@app/hooks/useClickOutside';
@@ -19,7 +18,7 @@ import {
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Fragment, useEffect, useRef } from 'react';
import { Fragment, useRef } from 'react';
import { useIntl } from 'react-intl';
export const menuMessages = defineMessages('components.Layout.Sidebar', {
@@ -36,10 +35,6 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
interface SidebarProps {
open?: boolean;
setClosed: () => void;
pendingRequestsCount: number;
openIssuesCount: number;
revalidateIssueCount: () => void;
revalidateRequestsCount: () => void;
}
interface SidebarLinkProps {
@@ -119,35 +114,13 @@ const SidebarLinks: SidebarLinkProps[] = [
},
];
const Sidebar = ({
open,
setClosed,
pendingRequestsCount,
openIssuesCount,
revalidateIssueCount,
revalidateRequestsCount,
}: SidebarProps) => {
const Sidebar = ({ open, setClosed }: SidebarProps) => {
const navRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const intl = useIntl();
const { hasPermission } = useUser();
useClickOutside(navRef, () => setClosed());
useEffect(() => {
if (openIssuesCount) {
revalidateIssueCount();
}
if (pendingRequestsCount) {
revalidateRequestsCount();
}
}, [
revalidateIssueCount,
revalidateRequestsCount,
pendingRequestsCount,
openIssuesCount,
]);
return (
<>
<div className="lg:hidden">
@@ -280,48 +253,18 @@ const Sidebar = ({
href={sidebarLink.href}
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
${
router.pathname.match(sidebarLink.activeRegExp)
? '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'
}
`}
${
router.pathname.match(sidebarLink.activeRegExp)
? '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'
}
`}
data-testid={sidebarLink.dataTestId}
>
{sidebarLink.svgIcon}
{intl.formatMessage(
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>
);
})}

View File

@@ -10,7 +10,6 @@ import { useUser } from '@app/hooks/useUser';
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
type LayoutProps = {
children: React.ReactNode;
@@ -23,18 +22,6 @@ const Layout = ({ children }: LayoutProps) => {
const router = useRouter();
const { currentSettings } = useSettings();
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(() => {
if (setLocale && user) {
@@ -68,21 +55,10 @@ 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="relative inset-0 h-full w-full bg-gradient-to-t from-gray-900 to-transparent" />
</div>
<Sidebar
open={isSidebarOpen}
setClosed={() => setSidebarOpen(false)}
pendingRequestsCount={requestResponse?.pending ?? 0}
openIssuesCount={issueResponse?.open ?? 0}
revalidateIssueCount={() => revalidateIssueCount()}
revalidateRequestsCount={() => revalidateRequestsCount()}
/>
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
<div className="sm:hidden">
<MobileMenu
pendingRequestsCount={requestResponse?.pending ?? 0}
openIssuesCount={issueResponse?.open ?? 0}
revalidateIssueCount={() => revalidateIssueCount()}
revalidateRequestsCount={() => revalidateRequestsCount()}
/>
<MobileMenu />
</div>
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">

View File

@@ -114,9 +114,6 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
autoComplete="current-password"
data-testid="password"
className="!bg-gray-700/80 placeholder:text-gray-400"
data-1pignore="false"
data-lpignore="false"
data-bwignore="false"
/>
</div>
<div className="flex">

View File

@@ -122,13 +122,15 @@ const ManageSlideOver = ({
const deleteMediaFile = async () => {
if (data.mediaInfo) {
// 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`, {
const res1 = await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, {
method: 'DELETE',
});
await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
if (!res1.ok) throw new Error();
const res2 = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
method: 'DELETE',
});
if (!res2.ok) throw new Error();
revalidate();
onClose();

View File

@@ -56,7 +56,6 @@ const MediaSlider = ({
},
{
initialSize: 2,
revalidateFirstPage: false,
}
);

View File

@@ -25,7 +25,7 @@ import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import ErrorPage from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
@@ -190,7 +190,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
})
) {
mediaLinks.push({
text: getAvailableMediaServerName(),
text: getAvalaibleMediaServerName(),
url: plexUrl,
svg: <PlayIcon />,
});
@@ -204,7 +204,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
})
) {
mediaLinks.push({
text: getAvailable4kMediaServerName(),
text: getAvalaible4kMediaServerName(),
url: plexUrl4k,
svg: <PlayIcon />,
});
@@ -292,7 +292,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
(provider) => provider.iso_3166_1 === streamingRegion
)?.flatrate ?? [];
function getAvailableMediaServerName() {
function getAvalaibleMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
@@ -304,7 +304,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
}
function getAvailable4kMediaServerName() {
function getAvalaible4kMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
@@ -505,7 +505,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/jellyseerr_poster_not_found.png'
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
@@ -594,45 +594,42 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</Button>
</Tooltip>
)}
{data?.mediaInfo?.status !== MediaStatus.BLACKLISTED &&
user?.userType !== UserType.PLEX && (
<>
{toggleWatchlist ? (
<Tooltip
content={intl.formatMessage(messages.addtowatchlist)}
{data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
<>
{toggleWatchlist ? (
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
<Button
buttonType={'ghost'}
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickWatchlistBtn}
>
<Button
buttonType={'ghost'}
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickWatchlistBtn}
>
{isUpdating ? (
<Spinner className="h-3" />
) : (
<StarIcon className={'h-3 text-amber-300'} />
)}
</Button>
</Tooltip>
) : (
<Tooltip
content={intl.formatMessage(messages.removefromwatchlist)}
{isUpdating ? (
<Spinner className="h-3" />
) : (
<StarIcon className={'h-3 text-amber-300'} />
)}
</Button>
</Tooltip>
) : (
<Tooltip
content={intl.formatMessage(messages.removefromwatchlist)}
>
<Button
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickDeleteWatchlistBtn}
>
<Button
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickDeleteWatchlistBtn}
>
{isUpdating ? (
<Spinner className="h-3" />
) : (
<MinusCircleIcon className={'h-3'} />
)}
</Button>
</Tooltip>
)}
</>
)}
{isUpdating ? (
<Spinner className="h-3" />
) : (
<MinusCircleIcon className={'h-3'} />
)}
</Button>
</Tooltip>
)}
</>
)}
<PlayButton links={mediaLinks} />
<RequestButton
mediaType="movie"

View File

@@ -2,7 +2,7 @@ interface PWAHeaderProps {
applicationTitle?: string;
}
const PWAHeader = ({ applicationTitle = 'Jellyseerr' }: PWAHeaderProps) => {
const PWAHeader = ({ applicationTitle = 'Overseerr' }: PWAHeaderProps) => {
return (
<>
<link

View File

@@ -20,7 +20,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import Link from 'next/link';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { mutate } from 'swr';
const messages = defineMessages('components.RequestBlock', {
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
@@ -60,7 +59,6 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
if (onUpdate) {
onUpdate();
mutate('/api/v1/request/count');
}
setIsUpdating(false);
};
@@ -74,7 +72,6 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
if (onUpdate) {
onUpdate();
mutate('/api/v1/request/count');
}
setIsUpdating(false);

View File

@@ -15,7 +15,6 @@ import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import { useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { mutate } from 'swr';
const messages = defineMessages('components.RequestButton', {
viewrequest: 'View Request',
@@ -102,7 +101,6 @@ const RequestButton = ({
if (data) {
onUpdate();
mutate('/api/v1/request/count');
}
};
@@ -125,7 +123,6 @@ const RequestButton = ({
);
onUpdate();
mutate('/api/v1/request/count');
};
const buttons: ButtonOption[] = [];

View File

@@ -80,7 +80,6 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
if (!res.ok) throw new Error();
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/count');
};
return (
@@ -272,7 +271,6 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
if (data) {
revalidate();
mutate('/api/v1/request/count');
}
};
@@ -282,7 +280,6 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
});
if (!res.ok) throw new Error();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
};
const retryRequest = async () => {
@@ -621,7 +618,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/jellyseerr_poster_not_found.png'
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"

View File

@@ -27,7 +27,7 @@ import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
const messages = defineMessages('components.RequestList.RequestItem', {
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
@@ -69,7 +69,6 @@ const RequestItemError = ({
});
if (!res.ok) throw new Error();
revalidateList();
mutate('/api/v1/request/count');
};
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
@@ -335,7 +334,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
if (data) {
revalidate();
mutate('/api/v1/request/count');
}
};
@@ -346,12 +344,10 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
if (!res.ok) throw new Error();
revalidateList();
mutate('/api/v1/request/count');
};
const deleteMediaFile = async () => {
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`, {
method: 'DELETE',
});
@@ -456,7 +452,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/jellyseerr_poster_not_found.png'
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"

View File

@@ -16,7 +16,7 @@ import type { Collection } from '@server/models/Collection';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
const messages = defineMessages('components.RequestModal', {
requestadmin: 'This request will be approved automatically.',
@@ -220,7 +220,6 @@ const CollectionRequestModal = ({
? MediaStatus.UNKNOWN
: MediaStatus.PARTIALLY_AVAILABLE
);
mutate('/api/v1/request/count');
}
addToast(
@@ -240,16 +239,7 @@ const CollectionRequestModal = ({
} finally {
setIsUpdating(false);
}
}, [
requestOverrides,
data?.parts,
data?.name,
onComplete,
addToast,
intl,
selectedParts,
is4k,
]);
}, [requestOverrides, data, onComplete, addToast, intl, selectedParts, is4k]);
const hasAutoApprove = hasPermission(
[
@@ -451,7 +441,7 @@ const CollectionRequestModal = ({
src={
part.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
: '/images/jellyseerr_poster_not_found.png'
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"

View File

@@ -104,7 +104,6 @@ const MovieRequestModal = ({
if (!res.ok) throw new Error();
const mediaRequest: MediaRequest = await res.json();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
if (mediaRequest) {
if (onComplete) {
@@ -139,16 +138,7 @@ const MovieRequestModal = ({
} finally {
setIsUpdating(false);
}
}, [
requestOverrides,
data?.id,
data?.title,
is4k,
onComplete,
addToast,
intl,
hasPermission,
]);
}, [data, onComplete, addToast, requestOverrides, hasPermission, intl, is4k]);
const cancelRequest = async () => {
setIsUpdating(true);
@@ -160,7 +150,6 @@ const MovieRequestModal = ({
if (!res.ok) throw new Error();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
if (res.status === 204) {
if (onComplete) {
@@ -208,7 +197,6 @@ const MovieRequestModal = ({
if (!res.ok) throw new Error();
}
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
addToast(
<span>

View File

@@ -92,7 +92,7 @@ const SearchByNameModal = ({
<Image
src={
item.remotePoster ??
'/images/jellyseerr_poster_not_found.png'
'/images/overseerr_poster_not_found.png'
}
alt={item.title}
className="w-100 h-auto rounded-md"

View File

@@ -106,7 +106,6 @@ const TvRequestModal = ({
if (onUpdating) {
onUpdating(true);
mutate('/api/v1/request/count');
}
try {
@@ -142,7 +141,6 @@ const TvRequestModal = ({
if (!res.ok) throw new Error();
}
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
addToast(
<span>
@@ -191,7 +189,6 @@ const TvRequestModal = ({
if (onUpdating) {
onUpdating(true);
mutate('/api/v1/request/count');
}
try {

View File

@@ -52,21 +52,18 @@ type SingleVal = {
type BaseSelectorMultiProps = {
defaultValue?: string;
isMulti: true;
isDisabled?: boolean;
onChange: (value: MultiValue<SingleVal> | null) => void;
};
type BaseSelectorSingleProps = {
defaultValue?: string;
isMulti?: false;
isDisabled?: boolean;
onChange: (value: SingleValue<SingleVal> | null) => void;
};
export const CompanySelector = ({
defaultValue,
isMulti,
isDisabled,
onChange,
}: BaseSelectorSingleProps | BaseSelectorMultiProps) => {
const intl = useIntl();
@@ -120,7 +117,6 @@ export const CompanySelector = ({
className="react-select-container"
classNamePrefix="react-select"
isMulti={isMulti}
isDisabled={isDisabled}
defaultValue={defaultDataValue}
defaultOptions
cacheOptions
@@ -147,7 +143,6 @@ type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & {
export const GenreSelector = ({
isMulti,
defaultValue,
isDisabled,
onChange,
type,
}: GenreSelectorProps) => {
@@ -208,7 +203,6 @@ export const GenreSelector = ({
defaultOptions
cacheOptions
isMulti={isMulti}
isDisabled={isDisabled}
loadOptions={loadGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
@@ -221,7 +215,6 @@ export const GenreSelector = ({
export const StatusSelector = ({
isMulti,
isDisabled,
defaultValue,
onChange,
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
@@ -279,7 +272,6 @@ export const StatusSelector = ({
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
defaultOptions
isMulti={isMulti}
isDisabled={isDisabled}
loadOptions={loadStatusOptions}
placeholder={intl.formatMessage(messages.searchStatus)}
onChange={(value) => {
@@ -292,7 +284,6 @@ export const StatusSelector = ({
export const KeywordSelector = ({
isMulti,
isDisabled,
defaultValue,
onChange,
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
@@ -350,7 +341,6 @@ export const KeywordSelector = ({
key={`keyword-select-${defaultDataValue}`}
inputId="data"
isMulti={isMulti}
isDisabled={isDisabled}
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
@@ -561,7 +551,6 @@ export const WatchProviderSelector = ({
export const UserSelector = ({
isMulti,
isDisabled,
defaultValue,
onChange,
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
@@ -624,7 +613,6 @@ export const UserSelector = ({
defaultOptions
cacheOptions
isMulti={isMulti}
isDisabled={isDisabled}
loadOptions={loadUserOptions}
placeholder={intl.formatMessage(messages.searchUsers)}
onChange={(value) => {

View File

@@ -1,54 +1,40 @@
import Tooltip from '@app/components/Common/Tooltip';
import defineMessages from '@app/utils/defineMessages';
import { ClipboardDocumentIcon } from '@heroicons/react/24/solid';
import React, { useEffect } from 'react';
import type { Config } from 'react-popper-tooltip';
import { useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useClipboard from 'react-use-clipboard';
type CopyButtonProps = {
textToCopy: string;
disabled?: boolean;
toastMessage?: string;
const messages = defineMessages('components.Settings', {
copied: 'Copied API key to clipboard.',
});
tooltipContent?: React.ReactNode;
tooltipConfig?: Partial<Config>;
};
const CopyButton = ({
textToCopy,
disabled,
toastMessage,
tooltipContent,
tooltipConfig,
}: CopyButtonProps) => {
const CopyButton = ({ textToCopy }: { textToCopy: string }) => {
const intl = useIntl();
const [isCopied, setCopied] = useClipboard(textToCopy, {
successDuration: 1000,
});
const { addToast } = useToasts();
useEffect(() => {
if (isCopied && toastMessage) {
addToast(toastMessage, {
if (isCopied) {
addToast(intl.formatMessage(messages.copied), {
appearance: 'info',
autoDismiss: true,
});
}
}, [isCopied, addToast, toastMessage]);
}, [isCopied, addToast, intl]);
return (
<Tooltip content={tooltipContent} tooltipConfig={tooltipConfig}>
<button
onClick={(e) => {
e.preventDefault();
setCopied();
}}
className="input-action"
type="button"
disabled={disabled}
>
<ClipboardDocumentIcon />
</button>
</Tooltip>
<button
onClick={(e) => {
e.preventDefault();
setCopied();
}}
className="input-action"
>
<ClipboardDocumentIcon />
</button>
);
};

View File

@@ -238,10 +238,6 @@ const NotificationsDiscord = () => {
name="botUsername"
type="text"
placeholder={settings.currentSettings.applicationTitle}
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
</div>
{errors.botUsername &&

View File

@@ -104,7 +104,7 @@ const NotificationsEmail = () => {
otherwise: Yup.string().nullable(),
})
.matches(
/-----BEGIN PGP PRIVATE KEY BLOCK-----.+-----END PGP PRIVATE KEY BLOCK-----/,
/-----BEGIN PGP PRIVATE KEY BLOCK-----.+-----END PGP PRIVATE KEY BLOCK-----/s,
intl.formatMessage(messages.validationPgpPrivateKey)
),
pgpPassword: Yup.string().when('pgpPrivateKey', {
@@ -295,10 +295,6 @@ const NotificationsEmail = () => {
name="emailFrom"
type="text"
inputMode="email"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
</div>
{errors.emailFrom &&
@@ -320,10 +316,6 @@ const NotificationsEmail = () => {
name="smtpHost"
type="text"
inputMode="url"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
</div>
{errors.smtpHost &&
@@ -345,10 +337,6 @@ const NotificationsEmail = () => {
type="text"
inputMode="numeric"
className="short"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
{errors.smtpPort &&
touched.smtpPort &&
@@ -402,15 +390,7 @@ const NotificationsEmail = () => {
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="authUser"
name="authUser"
type="text"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
<Field id="authUser" name="authUser" type="text" />
</div>
</div>
</div>
@@ -420,7 +400,12 @@ const NotificationsEmail = () => {
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput as="field" id="authPass" name="authPass" />
<SensitiveInput
as="field"
id="authPass"
name="authPass"
autoComplete="one-time-code"
/>
</div>
</div>
</div>
@@ -445,10 +430,6 @@ const NotificationsEmail = () => {
type="textarea"
rows="10"
className="font-mono text-xs"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
</div>
{errors.pgpPrivateKey &&
@@ -476,10 +457,7 @@ const NotificationsEmail = () => {
as="field"
id="pgpPassword"
name="pgpPassword"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
autoComplete="one-time-code"
/>
</div>
{errors.pgpPassword &&

View File

@@ -245,7 +245,7 @@ const NotificationsTelegram = () => {
as="field"
id="botAPI"
name="botAPI"
type="text"
autoComplete="one-time-code"
/>
</div>
{errors.botAPI &&
@@ -264,15 +264,7 @@ const NotificationsTelegram = () => {
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="botUsername"
name="botUsername"
type="text"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
<Field id="botUsername" name="botUsername" type="text" />
</div>
{errors.botUsername &&
touched.botUsername &&
@@ -302,15 +294,7 @@ const NotificationsTelegram = () => {
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="chatId"
name="chatId"
type="text"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
<Field id="chatId" name="chatId" type="text" />
</div>
{errors.chatId &&
touched.chatId &&

View File

@@ -11,13 +11,7 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import type OverrideRule from '@server/entity/OverrideRule';
import type {
DVRSettings,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
@@ -26,9 +20,6 @@ const messages = defineMessages('components.Settings.OverrideRuleModal', {
createrule: 'New Override Rule',
editrule: 'Edit Override Rule',
create: 'Create rule',
service: 'Service',
serviceDescription: 'Apply this rule to the selected service.',
selectService: 'Select service',
conditions: 'Conditions',
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).',
@@ -58,88 +49,21 @@ type OptionType = {
interface OverrideRuleModalProps {
rule: OverrideRule | null;
onClose: () => void;
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
testResponse: DVRTestResponse;
radarrId?: number;
sonarrId?: number;
}
const OverrideRuleModal = ({
onClose,
rule,
radarrServices,
sonarrServices,
testResponse,
radarrId,
sonarrId,
}: OverrideRuleModalProps) => {
const intl = useIntl();
const { addToast } = useToasts();
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 (
<Transition
@@ -155,8 +79,6 @@ const OverrideRuleModal = ({
>
<Formik
initialValues={{
radarrServiceId: rule?.radarrServiceId,
sonarrServiceId: rule?.sonarrServiceId,
users: rule?.users,
genre: rule?.genre,
language: rule?.language,
@@ -175,8 +97,8 @@ const OverrideRuleModal = ({
profileId: Number(values.profileId) || null,
rootFolder: values.rootFolder || null,
tags: values.tags || null,
radarrServiceId: values.radarrServiceId,
sonarrServiceId: values.sonarrServiceId,
radarrServiceId: radarrId,
sonarrServiceId: sonarrId,
};
if (!rule) {
const res = await fetch('/api/v1/overrideRule', {
@@ -248,75 +170,6 @@ const OverrideRuleModal = ({
}
>
<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">
{intl.formatMessage(messages.conditions)}
</h3>
@@ -331,7 +184,6 @@ const OverrideRuleModal = ({
<div className="form-input-field">
<UserSelector
defaultValue={values.users}
isDisabled={!isValidated || isTesting}
isMulti
onChange={(users) => {
setFieldValue(
@@ -355,10 +207,9 @@ const OverrideRuleModal = ({
<div className="form-input-area">
<div className="form-input-field">
<GenreSelector
type={values.radarrServiceId ? 'movie' : 'tv'}
type={radarrId ? 'movie' : 'tv'}
defaultValue={values.genre}
isMulti
isDisabled={!isValidated || isTesting}
onChange={(genres) => {
setFieldValue(
'genre',
@@ -386,7 +237,6 @@ const OverrideRuleModal = ({
setFieldValue={(_key, value) => {
setFieldValue('language', value);
}}
isDisabled={!isValidated || isTesting}
/>
</div>
{errors.language &&
@@ -405,7 +255,6 @@ const OverrideRuleModal = ({
<KeywordSelector
defaultValue={values.keywords}
isMulti
isDisabled={!isValidated || isTesting}
onChange={(value) => {
setFieldValue(
'keywords',
@@ -433,12 +282,7 @@ const OverrideRuleModal = ({
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolderRule"
name="rootFolder"
disabled={!isValidated || isTesting}
>
<Field as="select" id="rootFolderRule" name="rootFolder">
<option value="">
{intl.formatMessage(messages.selectRootFolder)}
</option>
@@ -466,12 +310,7 @@ const OverrideRuleModal = ({
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="profileIdRule"
name="profileId"
disabled={!isValidated || isTesting}
>
<Field as="select" id="profileIdRule" name="profileId">
<option value="">
{intl.formatMessage(messages.selectQualityProfile)}
</option>
@@ -504,7 +343,6 @@ const OverrideRuleModal = ({
value: tag.id,
}))}
isMulti
isDisabled={!isValidated || isTesting}
placeholder={intl.formatMessage(messages.selecttags)}
className="react-select-container"
classNamePrefix="react-select"

View File

@@ -0,0 +1,267 @@
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;

View File

@@ -1,318 +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 {
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 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.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;

View File

@@ -1,15 +1,24 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import type { RadarrTestResponse } from '@app/components/Settings/SettingsServices';
import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile';
import type {
DVRTestResponse,
RadarrTestResponse,
} from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
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 { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
type OptionType = {
@@ -70,16 +79,36 @@ const messages = defineMessages('components.Settings.RadarrModal', {
announced: 'Announced',
inCinemas: 'In Cinemas',
released: 'Released',
overrideRules: 'Override Rules',
addrule: 'New Override Rule',
});
interface RadarrModalProps {
radarr: RadarrSettings | null;
onClose: () => void;
onSave: () => void;
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
setOverrideRuleModal: ({
open,
rule,
testResponse,
}: {
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse;
}) => void;
}
const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
const RadarrModal = ({
onClose,
radarr,
onSave,
overrideRuleModal,
setOverrideRuleModal,
}: RadarrModalProps) => {
const intl = useIntl();
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const initialLoad = useRef(false);
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(radarr ? true : false);
@@ -206,6 +235,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
}
}, [radarr, testConnection]);
useEffect(() => {
revalidate();
}, [overrideRuleModal, revalidate]);
return (
<Transition
as="div"
@@ -349,6 +382,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
values.is4k ? messages.edit4kradarr : messages.editradarr
)
}
backgroundClickable={!overrideRuleModal.open}
>
<div className="mb-6">
<div className="form-row">
@@ -382,10 +416,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
id="name"
name="name"
type="text"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('name', e.target.value);
@@ -479,6 +509,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
as="field"
id="apiKey"
name="apiKey"
autoComplete="one-time-code"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('apiKey', e.target.value);
@@ -742,6 +773,42 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
</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>
);
}}

View File

@@ -67,14 +67,11 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
'download-sync': 'Download Sync',
'download-sync-reset': 'Download Sync Reset',
'image-cache-cleanup': 'Image Cache Cleanup',
'process-blacklisted-tags': 'Process Blacklisted Tags',
editJobSchedule: 'Modify Job',
jobScheduleEditSaved: 'Job edited successfully!',
jobScheduleEditFailed: 'Something went wrong while saving the job.',
editJobScheduleCurrent: 'Current Frequency',
editJobSchedulePrompt: 'New Frequency',
editJobScheduleSelectorDays:
'Every {jobScheduleDays, plural, one {day} other {{jobScheduleDays} days}}',
editJobScheduleSelectorHours:
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
editJobScheduleSelectorMinutes:
@@ -94,7 +91,7 @@ interface Job {
id: JobId;
name: string;
type: 'process' | 'command';
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
nextExecutionTime: string;
running: boolean;
@@ -103,20 +100,13 @@ interface Job {
type JobModalState = {
isOpen?: boolean;
job?: Job;
scheduleDays: number;
scheduleHours: number;
scheduleMinutes: number;
scheduleSeconds: number;
};
type JobModalAction =
| {
type: 'set';
days?: number;
hours?: number;
minutes?: number;
seconds?: number;
}
| { type: 'set'; hours?: number; minutes?: number; seconds?: number }
| {
type: 'close';
}
@@ -137,7 +127,6 @@ const jobModalReducer = (
return {
isOpen: true,
job: action.job,
scheduleDays: 1,
scheduleHours: 1,
scheduleMinutes: 5,
scheduleSeconds: 30,
@@ -146,7 +135,6 @@ const jobModalReducer = (
case 'set':
return {
...state,
scheduleDays: action.days ?? state.scheduleDays,
scheduleHours: action.hours ?? state.scheduleHours,
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
@@ -175,7 +163,6 @@ const SettingsJobs = () => {
const [jobModalState, dispatch] = useReducer(jobModalReducer, {
isOpen: false,
scheduleDays: 1,
scheduleHours: 1,
scheduleMinutes: 5,
scheduleSeconds: 30,
@@ -260,9 +247,6 @@ const SettingsJobs = () => {
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
} else if (jobModalState.job?.interval === 'hours') {
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
} else if (jobModalState.job?.interval === 'days') {
jobScheduleCron[2] = '1';
jobScheduleCron[3] = `*/${jobModalState.scheduleDays}`;
} else {
// jobs with interval: fixed should not be editable
throw new Error();
@@ -398,29 +382,6 @@ const SettingsJobs = () => {
</option>
))}
</select>
) : jobModalState.job?.interval === 'days' ? (
<select
name="jobScheduleDays"
className="inline"
value={jobModalState.scheduleDays}
onChange={(e) =>
dispatch({
type: 'set',
days: Number(e.target.value),
})
}
>
{[1, 2, 3, 4, 5, 6, 7, 10, 14, 21].map((v) => (
<option value={v} key={`jobScheduleDays-${v}`}>
{intl.formatMessage(
messages.editJobScheduleSelectorDays,
{
jobScheduleDays: v,
}
)}
</option>
))}
</select>
) : (
<select
name="jobScheduleHours"

View File

@@ -13,7 +13,6 @@ const messages = defineMessages('components.Settings', {
menuPlexSettings: 'Plex',
menuJellyfinSettings: '{mediaServerName}',
menuServices: 'Services',
menuNetwork: 'Network',
menuNotifications: 'Notifications',
menuLogs: 'Logs',
menuJobs: 'Jobs & Cache',
@@ -54,11 +53,6 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
route: '/settings/services',
regex: /^\/settings\/services/,
},
{
text: intl.formatMessage(messages.menuNetwork),
route: '/settings/network',
regex: /^\/settings\/network/,
},
{
text: intl.formatMessage(messages.menuNotifications),
route: '/settings/notifications/email',

View File

@@ -35,7 +35,7 @@ import useSWR from 'swr';
const messages = defineMessages('components.Settings.SettingsLogs', {
logs: 'Logs',
logsDescription:
'You can also view these logs directly via <code>stdout</code>, or in <code>{appDataPath}/logs/jellyseerr.log</code>.',
'You can also view these logs directly via <code>stdout</code>, or in <code>{appDataPath}/logs/overseerr.log</code>.',
time: 'Timestamp',
level: 'Severity',
label: 'Label',

View File

@@ -1,8 +1,8 @@
import BlacklistedTagsSelector from '@app/components/BlacklistedTagsSelector';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import Tooltip from '@app/components/Common/Tooltip';
import LanguageSelector from '@app/components/LanguageSelector';
import RegionSelector from '@app/components/RegionSelector';
import CopyButton from '@app/components/Settings/CopyButton';
@@ -29,19 +29,12 @@ const messages = defineMessages('components.Settings.SettingsMain', {
generalsettingsDescription:
'Configure global and default settings for Jellyseerr.',
apikey: 'API Key',
apikeyCopied: 'Copied API key to clipboard.',
applicationTitle: 'Application Title',
applicationurl: 'Application URL',
discoverRegion: 'Discover Region',
discoverRegionTip: 'Filter content by regional availability',
originallanguage: 'Discover Language',
originallanguageTip: 'Filter content by original language',
blacklistedTags: 'Blacklist Content with Tags',
blacklistedTagsTip:
'Automatically add content with tags to the blacklist using the "Process Blacklisted Tags" job',
blacklistedTagsLimit: 'Limit Content Blacklisted per Tag',
blacklistedTagsLimitTip:
'The "Process Blacklisted Tags" job will blacklist this many pages into each sort. Larger numbers will create a more accurate blacklist, but use more space.',
streamingRegion: 'Streaming Region',
streamingRegionTip: 'Show streaming sites by regional availability',
toastApiKeySuccess: 'New API key generated successfully!',
@@ -49,15 +42,39 @@ const messages = defineMessages('components.Settings.SettingsMain', {
toastSettingsSuccess: 'Settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.',
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',
cacheImagesTip:
'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',
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series 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',
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 = () => {
@@ -88,17 +105,12 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
blacklistedTagsLimit: Yup.number()
.test(
'positive',
'Number must be greater than 0.',
(value) => (value ?? 0) >= 0
)
.test(
'lte-250',
'Number must be less than or equal to 250.',
(value) => (value ?? 0) <= 250
proxyPort: Yup.number().when('proxyEnabled', {
is: (proxyEnabled: boolean) => proxyEnabled,
then: Yup.number().required(
intl.formatMessage(messages.validationProxyPort)
),
}),
});
const regenerate = async () => {
@@ -146,16 +158,26 @@ const SettingsMain = () => {
initialValues={{
applicationTitle: data?.applicationTitle,
applicationUrl: data?.applicationUrl,
csrfProtection: data?.csrfProtection,
hideAvailable: data?.hideAvailable,
locale: data?.locale ?? 'en',
discoverRegion: data?.discoverRegion,
originalLanguage: data?.originalLanguage,
streamingRegion: data?.streamingRegion || 'US',
blacklistedTags: data?.blacklistedTags,
blacklistedTagsLimit: data?.blacklistedTagsLimit || 50,
partialRequestsEnabled: data?.partialRequestsEnabled,
enableSpecialEpisodes: data?.enableSpecialEpisodes,
forceIpv4First: data?.forceIpv4First,
dnsServers: data?.dnsServers,
trustProxy: data?.trustProxy,
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
validationSchema={MainSettingsSchema}
@@ -169,16 +191,28 @@ const SettingsMain = () => {
body: JSON.stringify({
applicationTitle: values.applicationTitle,
applicationUrl: values.applicationUrl,
csrfProtection: values.csrfProtection,
hideAvailable: values.hideAvailable,
locale: values.locale,
discoverRegion: values.discoverRegion,
streamingRegion: values.streamingRegion,
originalLanguage: values.originalLanguage,
blacklistedTags: values.blacklistedTags,
blacklistedTagsLimit: values.blacklistedTagsLimit,
partialRequestsEnabled: values.partialRequestsEnabled,
enableSpecialEpisodes: values.enableSpecialEpisodes,
forceIpv4First: values.forceIpv4First,
dnsServers: values.dnsServers,
trustProxy: values.trustProxy,
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();
@@ -233,9 +267,6 @@ const SettingsMain = () => {
/>
<CopyButton
textToCopy={data?.apiKey ?? ''}
toastMessage={intl.formatMessage(
messages.apikeyCopied
)}
key={data?.apiKey}
/>
<button
@@ -244,7 +275,6 @@ const SettingsMain = () => {
regenerate();
}}
className="input-action"
type="button"
>
<ArrowPathIcon />
</button>
@@ -291,6 +321,58 @@ const SettingsMain = () => {
)}
</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">
<label htmlFor="cacheImages" className="checkbox-label">
<span className="mr-2">
@@ -388,49 +470,6 @@ const SettingsMain = () => {
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="blacklistedTags" className="text-label">
<span>{intl.formatMessage(messages.blacklistedTags)}</span>
<span className="label-tip">
{intl.formatMessage(messages.blacklistedTagsTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field relative z-10">
<BlacklistedTagsSelector
defaultValue={values.blacklistedTags}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="blacklistedTagsLimit" className="text-label">
<span className="mr-2">
{intl.formatMessage(messages.blacklistedTagsLimit)}
</span>
<SettingsBadge badgeType="advanced" />
<span className="label-tip">
{intl.formatMessage(messages.blacklistedTagsLimitTip)}
</span>
</label>
<div className="form-input-area">
<Field
id="blacklistedTagsLimit"
name="blacklistedTagsLimit"
type="text"
inputMode="numeric"
className="short"
placeholder={50}
/>
{errors.blacklistedTagsLimit &&
touched.blacklistedTagsLimit &&
typeof errors.blacklistedTagsLimit === 'string' && (
<div className="error">
{errors.blacklistedTagsLimit}
</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="hideAvailable" className="checkbox-label">
<span className="mr-2">
@@ -495,6 +534,231 @@ const SettingsMain = () => {
/>
</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="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">

View File

@@ -1,484 +0,0 @@
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',
dnsServers: 'Custom DNS Servers',
dnsServersTip:
'Comma-separated list of custom DNS servers, e.g. "1.1.1.1,[2606:4700:4700::1111]"',
});
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,
dnsServers: data?.dnsServers,
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,
dnsServers: values.dnsServers,
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="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" />
<SettingsBadge badgeType="experimental" />
<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="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;

View File

@@ -872,10 +872,6 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
id="tautulliPort"
name="tautulliPort"
className="short"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
{errors.tautulliPort &&
touched.tautulliPort &&
@@ -913,10 +909,6 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
inputMode="url"
id="tautulliUrlBase"
name="tautulliUrlBase"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
</div>
{errors.tautulliUrlBase &&
@@ -937,6 +929,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
as="field"
id="tautulliApiKey"
name="tautulliApiKey"
autoComplete="one-time-code"
/>
</div>
{errors.tautulliApiKey &&
@@ -957,10 +950,6 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
inputMode="url"
id="tautulliExternalUrl"
name="tautulliExternalUrl"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
</div>
{errors.tautulliExternalUrl &&

View File

@@ -7,7 +7,6 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
import OverrideRuleTiles from '@app/components/Settings/OverrideRule/OverrideRuleTiles';
import RadarrModal from '@app/components/Settings/RadarrModal';
import SonarrModal from '@app/components/Settings/SonarrModal';
import globalMessages from '@app/i18n/globalMessages';
@@ -15,7 +14,6 @@ import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
import type OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { Fragment, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -45,10 +43,6 @@ const messages = defineMessages('components.Settings', {
mediaTypeMovie: 'movie',
mediaTypeSeries: 'series',
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 {
@@ -119,8 +113,6 @@ const ServerInstance = ({
<h3 className="truncate font-medium leading-5 text-white">
<a
href={serviceUrl}
target="_blank"
rel="noopener noreferrer"
className="transition duration-300 hover:text-white hover:underline"
>
{name}
@@ -149,8 +141,6 @@ const ServerInstance = ({
</span>
<a
href={internalUrl}
target="_blank"
rel="noopener noreferrer"
className="transition duration-300 hover:text-white hover:underline"
>
{internalUrl}
@@ -163,12 +153,7 @@ const ServerInstance = ({
{profileName}
</p>
</div>
<a
href={serviceUrl}
target="_blank"
rel="noopener noreferrer"
className="opacity-50 hover:opacity-100"
>
<a href={serviceUrl} className="opacity-50 hover:opacity-100">
{isSonarr ? (
<SonarrLogo className="h-10 w-10 flex-shrink-0" />
) : (
@@ -214,8 +199,6 @@ const SettingsServices = () => {
error: sonarrError,
mutate: revalidateSonarr,
} = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr');
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const [editRadarrModal, setEditRadarrModal] = useState<{
open: boolean;
radarr: RadarrSettings | null;
@@ -242,9 +225,11 @@ const SettingsServices = () => {
const [overrideRuleModal, setOverrideRuleModal] = useState<{
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse | null;
}>({
open: false,
rule: null,
testResponse: null,
});
const deleteServer = async () => {
@@ -280,6 +265,21 @@ const SettingsServices = () => {
})}
</p>
</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 && (
<RadarrModal
radarr={editRadarrModal.radarr}
@@ -292,6 +292,8 @@ const SettingsServices = () => {
mutate('/api/v1/settings/public');
setEditRadarrModal({ open: false, radarr: null });
}}
overrideRuleModal={overrideRuleModal}
setOverrideRuleModal={setOverrideRuleModal}
/>
)}
{editSonarrModal.open && (
@@ -306,6 +308,8 @@ const SettingsServices = () => {
mutate('/api/v1/settings/public');
setEditSonarrModal({ open: false, sonarr: null });
}}
overrideRuleModal={overrideRuleModal}
setOverrideRuleModal={setOverrideRuleModal}
/>
)}
<Transition
@@ -503,59 +507,6 @@ const SettingsServices = () => {
</>
)}
</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"
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}
/>
)}
</>
);
};

View File

@@ -1,9 +1,17 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import type { SonarrTestResponse } from '@app/components/Settings/SettingsServices';
import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile';
import type {
DVRTestResponse,
SonarrTestResponse,
} from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
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 { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -11,6 +19,7 @@ import { useIntl } from 'react-intl';
import type { OnChangeValue } from 'react-select';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
type OptionType = {
@@ -76,16 +85,36 @@ const messages = defineMessages('components.Settings.SonarrModal', {
animeTags: 'Anime Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
overrideRules: 'Override Rules',
addrule: 'New Override Rule',
});
interface SonarrModalProps {
sonarr: SonarrSettings | null;
onClose: () => void;
onSave: () => void;
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
setOverrideRuleModal: ({
open,
rule,
testResponse,
}: {
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse;
}) => void;
}
const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
const SonarrModal = ({
onClose,
sonarr,
onSave,
overrideRuleModal,
setOverrideRuleModal,
}: SonarrModalProps) => {
const intl = useIntl();
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const initialLoad = useRef(false);
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
@@ -215,6 +244,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
}
}, [sonarr, testConnection]);
useEffect(() => {
revalidate();
}, [overrideRuleModal, revalidate]);
return (
<Transition
as="div"
@@ -382,6 +415,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
values.is4k ? messages.edit4ksonarr : messages.editsonarr
)
}
backgroundClickable={!overrideRuleModal.open}
>
<div className="mb-6">
<div className="form-row">
@@ -415,10 +449,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
id="name"
name="name"
type="text"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('name', e.target.value);
@@ -512,6 +542,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
as="field"
id="apiKey"
name="apiKey"
autoComplete="one-time-code"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('apiKey', e.target.value);
@@ -1039,6 +1070,42 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
</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>
);
}}

View File

@@ -8,7 +8,7 @@ import RequestModal from '@app/components/RequestModal';
import ErrorCard from '@app/components/TitleCard/ErrorCard';
import Placeholder from '@app/components/TitleCard/Placeholder';
import { useIsTouch } from '@app/hooks/useIsTouch';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { withProperties } from '@app/utils/typeHelpers';
@@ -352,7 +352,7 @@ const TitleCard = ({
src={
image
? `https://image.tmdb.org/t/p/w300_and_h450_face${image}`
: `/images/jellyseerr_poster_not_found_logo_top.png`
: `/images/overseerr_poster_not_found_logo_top.png`
}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
@@ -373,44 +373,42 @@ const TitleCard = ({
: intl.formatMessage(globalMessages.tvshow)}
</div>
</div>
{showDetail &&
currentStatus !== MediaStatus.BLACKLISTED &&
user?.userType !== UserType.PLEX && (
<div className="flex flex-col gap-1">
{toggleWatchlist ? (
{showDetail && currentStatus !== MediaStatus.BLACKLISTED && (
<div className="flex flex-col gap-1">
{toggleWatchlist ? (
<Button
buttonType={'ghost'}
className="z-40"
buttonSize={'sm'}
onClick={onClickWatchlistBtn}
>
<StarIcon className={'h-3 text-amber-300'} />
</Button>
) : (
<Button
className="z-40"
buttonSize={'sm'}
onClick={onClickDeleteWatchlistBtn}
>
<MinusCircleIcon className={'h-3'} />
</Button>
)}
{showHideButton &&
currentStatus !== MediaStatus.PROCESSING &&
currentStatus !== MediaStatus.AVAILABLE &&
currentStatus !== MediaStatus.PARTIALLY_AVAILABLE &&
currentStatus !== MediaStatus.PENDING && (
<Button
buttonType={'ghost'}
className="z-40"
buttonSize={'sm'}
onClick={onClickWatchlistBtn}
onClick={() => setShowBlacklistModal(true)}
>
<StarIcon className={'h-3 text-amber-300'} />
</Button>
) : (
<Button
className="z-40"
buttonSize={'sm'}
onClick={onClickDeleteWatchlistBtn}
>
<MinusCircleIcon className={'h-3'} />
<EyeSlashIcon className={'h-3'} />
</Button>
)}
{showHideButton &&
currentStatus !== MediaStatus.PROCESSING &&
currentStatus !== MediaStatus.AVAILABLE &&
currentStatus !== MediaStatus.PARTIALLY_AVAILABLE &&
currentStatus !== MediaStatus.PENDING && (
<Button
buttonType={'ghost'}
className="z-40"
buttonSize={'sm'}
onClick={() => setShowBlacklistModal(true)}
>
<EyeSlashIcon className={'h-3'} />
</Button>
)}
</div>
)}
</div>
)}
{showDetail &&
showHideButton &&
currentStatus == MediaStatus.BLACKLISTED && (

View File

@@ -28,7 +28,7 @@ import Season from '@app/components/TvDetails/Season';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
@@ -187,7 +187,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
})
) {
mediaLinks.push({
text: getAvailableMediaServerName(),
text: getAvalaibleMediaServerName(),
url: plexUrl,
svg: <PlayIcon />,
});
@@ -201,7 +201,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
})
) {
mediaLinks.push({
text: getAvailable4kMediaServerName(),
text: getAvalaible4kMediaServerName(),
url: plexUrl4k,
svg: <PlayIcon />,
});
@@ -322,7 +322,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
(provider) => provider.iso_3166_1 === streamingRegion
)?.flatrate ?? [];
function getAvailableMediaServerName() {
function getAvalaibleMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
@@ -334,7 +334,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
}
function getAvailable4kMediaServerName() {
function getAvalaible4kMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
@@ -547,7 +547,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/jellyseerr_poster_not_found.png'
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
@@ -636,45 +636,42 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
</Button>
</Tooltip>
)}
{data?.mediaInfo?.status !== MediaStatus.BLACKLISTED &&
user?.userType !== UserType.PLEX && (
<>
{toggleWatchlist ? (
<Tooltip
content={intl.formatMessage(messages.addtowatchlist)}
{data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
<>
{toggleWatchlist ? (
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
<Button
buttonType={'ghost'}
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickWatchlistBtn}
>
<Button
buttonType={'ghost'}
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickWatchlistBtn}
>
{isUpdating ? (
<Spinner className="h-3" />
) : (
<StarIcon className={'h-3 text-amber-300'} />
)}
</Button>
</Tooltip>
) : (
<Tooltip
content={intl.formatMessage(messages.removefromwatchlist)}
{isUpdating ? (
<Spinner className="h-3" />
) : (
<StarIcon className={'h-3 text-amber-300'} />
)}
</Button>
</Tooltip>
) : (
<Tooltip
content={intl.formatMessage(messages.removefromwatchlist)}
>
<Button
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickDeleteWatchlistBtn}
>
<Button
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickDeleteWatchlistBtn}
>
{isUpdating ? (
<Spinner className="h-3" />
) : (
<MinusCircleIcon className={'h-3'} />
)}
</Button>
</Tooltip>
)}
</>
)}
{isUpdating ? (
<Spinner className="h-3" />
) : (
<MinusCircleIcon className={'h-3'} />
)}
</Button>
</Tooltip>
)}
</>
)}
<PlayButton links={mediaLinks} />
<RequestButton
mediaType="tv"

View File

@@ -210,9 +210,7 @@ const UserList = () => {
username: Yup.string().required(
intl.formatMessage(messages.validationUsername)
),
email: Yup.string()
.required()
.email(intl.formatMessage(messages.validationEmail)),
email: Yup.string().email(intl.formatMessage(messages.validationEmail)),
password: Yup.lazy((value) =>
!value
? Yup.string()
@@ -390,7 +388,6 @@ const UserList = () => {
<div className="form-row">
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
@@ -399,10 +396,6 @@ const UserList = () => {
name="email"
type="text"
inputMode="email"
autoComplete="off"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
/>
</div>
{errors.email &&

View File

@@ -100,9 +100,7 @@ const UserGeneralSettings = () => {
const UserGeneralSettingsSchema = Yup.object().shape({
email:
// email is required for everybody except non-admin jellyfin users
user?.id === 1 ||
(user?.userType !== UserType.JELLYFIN && user?.userType !== UserType.EMBY)
user?.id === 1
? Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired))

View File

@@ -1,188 +0,0 @@
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;

View File

@@ -1,276 +0,0 @@
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;

View File

@@ -18,7 +18,6 @@ import useSWR from 'swr';
const messages = defineMessages('components.UserProfile.UserSettings', {
menuGeneralSettings: 'General',
menuChangePass: 'Password',
menuLinkedAccounts: 'Linked Accounts',
menuNotifications: 'Notifications',
menuPermissions: 'Permissions',
unauthorizedDescription:
@@ -64,11 +63,6 @@ const UserSettings = ({ children }: UserSettingsProps) => {
currentUser?.id !== user?.id &&
hasPermission(Permission.ADMIN, user?.permissions ?? 0)),
},
{
text: intl.formatMessage(messages.menuLinkedAccounts),
route: '/settings/linked-accounts',
regex: /\/settings\/linked-accounts/,
},
{
text: intl.formatMessage(messages.menuNotifications),
route: data?.emailEnabled

View File

@@ -10,7 +10,7 @@ export interface SettingsContextProps {
const defaultSettings = {
initialized: false,
applicationTitle: 'Jellyseerr',
applicationTitle: 'Overseerr',
applicationUrl: '',
hideAvailable: false,
localLogin: true,

View File

@@ -80,7 +80,6 @@ const useDiscover = <
},
{
initialSize: 3,
revalidateFirstPage: false,
}
);

View File

@@ -11,8 +11,8 @@ export type { PermissionCheckOptions };
export interface User {
id: number;
warnings: string[];
plexUsername?: string | null;
jellyfinUsername?: string | null;
plexUsername?: string;
jellyfinUsername?: string;
username?: string;
displayName: string;
email: string;

View File

@@ -10,12 +10,9 @@
"components.Blacklist.blacklistdate": "date",
"components.Blacklist.blacklistedby": "{date} by {user}",
"components.Blacklist.blacklistsettings": "Blacklist Settings",
"components.Blacklist.filterBlacklistedTags": "Blacklisted Tags",
"components.Blacklist.filterManual": "Manual",
"components.Blacklist.mediaName": "Name",
"components.Blacklist.mediaTmdbId": "tmdb Id",
"components.Blacklist.mediaType": "Type",
"components.Blacklist.showAllBlacklisted": "Show All Blacklisted Media",
"components.CollectionDetails.numberofmovies": "{count} Movies",
"components.CollectionDetails.overview": "Overview",
"components.CollectionDetails.requestcollection": "Request Collection",
@@ -103,6 +100,7 @@
"components.Discover.StudioSlider.studios": "Studios",
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
"components.Discover.createnewslider": "Create New Slider",
"components.Discover.customizediscover": "Customize Discover",
"components.Discover.discover": "Discover",
@@ -136,7 +134,6 @@
"components.Discover.upcomingtv": "Upcoming Series",
"components.Discover.updatefailed": "Something went wrong updating the discover customization settings.",
"components.Discover.updatesuccess": "Updated discover customization settings.",
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
"components.DownloadBlock.estimatedtime": "Estimated {time}",
"components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}",
"components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?",
@@ -756,10 +753,7 @@
"components.Settings.OverrideRuleModal.ruleUpdated": "Override rule updated successfully!",
"components.Settings.OverrideRuleModal.selectQualityProfile": "Select quality profile",
"components.Settings.OverrideRuleModal.selectRootFolder": "Select root folder",
"components.Settings.OverrideRuleModal.selectService": "Select service",
"components.Settings.OverrideRuleModal.selecttags": "Select tags",
"components.Settings.OverrideRuleModal.service": "Service",
"components.Settings.OverrideRuleModal.serviceDescription": "Apply this rule to the selected service.",
"components.Settings.OverrideRuleModal.settings": "Settings",
"components.Settings.OverrideRuleModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.",
"components.Settings.OverrideRuleModal.tags": "Tags",
@@ -774,6 +768,7 @@
"components.Settings.OverrideRuleTile.tags": "Tags",
"components.Settings.OverrideRuleTile.users": "Users",
"components.Settings.RadarrModal.add": "Add Server",
"components.Settings.RadarrModal.addrule": "New Override Rule",
"components.Settings.RadarrModal.announced": "Announced",
"components.Settings.RadarrModal.apiKey": "API Key",
"components.Settings.RadarrModal.baseUrl": "URL Base",
@@ -792,6 +787,7 @@
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
"components.Settings.RadarrModal.notagoptions": "No tags.",
"components.Settings.RadarrModal.overrideRules": "Override Rules",
"components.Settings.RadarrModal.port": "Port",
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
"components.Settings.RadarrModal.released": "Released",
@@ -865,7 +861,6 @@
"components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job",
"components.Settings.SettingsJobsCache.editJobScheduleCurrent": "Current Frequency",
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorDays": "Every {jobScheduleDays, plural, one {day} other {{jobScheduleDays} days}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
@@ -892,7 +887,6 @@
"components.Settings.SettingsJobsCache.plex-refresh-token": "Plex Refresh Token",
"components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync",
"components.Settings.SettingsJobsCache.process": "Process",
"components.Settings.SettingsJobsCache.process-blacklisted-tags": "Process Blacklisted Tags",
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
"components.Settings.SettingsJobsCache.runnow": "Run Now",
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
@@ -909,7 +903,7 @@
"components.Settings.SettingsLogs.level": "Severity",
"components.Settings.SettingsLogs.logDetails": "Log Details",
"components.Settings.SettingsLogs.logs": "Logs",
"components.Settings.SettingsLogs.logsDescription": "You can also view these logs directly via <code>stdout</code>, or in <code>{appDataPath}/logs/jellyseerr.log</code>.",
"components.Settings.SettingsLogs.logsDescription": "You can also view these logs directly via <code>stdout</code>, or in <code>{appDataPath}/logs/overseerr.log</code>.",
"components.Settings.SettingsLogs.message": "Message",
"components.Settings.SettingsLogs.pauseLogs": "Pause",
"components.Settings.SettingsLogs.resumeLogs": "Resume",
@@ -917,18 +911,20 @@
"components.Settings.SettingsLogs.time": "Timestamp",
"components.Settings.SettingsLogs.viewdetails": "View Details",
"components.Settings.SettingsMain.apikey": "API Key",
"components.Settings.SettingsMain.apikeyCopied": "Copied API key to clipboard.",
"components.Settings.SettingsMain.applicationTitle": "Application Title",
"components.Settings.SettingsMain.applicationurl": "Application URL",
"components.Settings.SettingsMain.blacklistedTags": "Blacklist Content with Tags",
"components.Settings.SettingsMain.blacklistedTagsLimit": "Limit Content Blacklisted per Tag",
"components.Settings.SettingsMain.blacklistedTagsLimitTip": "The \"Process Blacklisted Tags\" job will blacklist this many pages into each sort. Larger numbers will create a more accurate blacklist, but use more space.",
"components.Settings.SettingsMain.blacklistedTagsTip": "Automatically add content with tags to the blacklist using the \"Process Blacklisted Tags\" job",
"components.Settings.SettingsMain.cacheImages": "Enable Image Caching",
"components.Settings.SettingsMain.cacheImagesTip": "Cache externally sourced images (requires a significant amount of disk space)",
"components.Settings.SettingsMain.csrfProtection": "Enable CSRF Protection",
"components.Settings.SettingsMain.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
"components.Settings.SettingsMain.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
"components.Settings.SettingsMain.discoverRegion": "Discover Region",
"components.Settings.SettingsMain.discoverRegionTip": "Filter content by regional availability",
"components.Settings.SettingsMain.dnsServers": "Custom DNS Servers",
"components.Settings.SettingsMain.dnsServersTip": "Comma-separated list of custom DNS servers, e.g. \"1.1.1.1,[2606:4700:4700::1111]\"",
"components.Settings.SettingsMain.enableSpecialEpisodes": "Allow Special Episodes Requests",
"components.Settings.SettingsMain.forceIpv4First": "IPv4 Resolution First",
"components.Settings.SettingsMain.forceIpv4FirstTip": "Force Jellyseerr to resolve IPv4 addresses first instead of IPv6",
"components.Settings.SettingsMain.general": "General",
"components.Settings.SettingsMain.generalsettings": "General Settings",
"components.Settings.SettingsMain.generalsettingsDescription": "Configure global and default settings for Jellyseerr.",
@@ -937,42 +933,27 @@
"components.Settings.SettingsMain.originallanguage": "Discover Language",
"components.Settings.SettingsMain.originallanguageTip": "Filter content by original language",
"components.Settings.SettingsMain.partialRequestsEnabled": "Allow Partial Series Requests",
"components.Settings.SettingsMain.proxyBypassFilter": "Proxy Ignored Addresses",
"components.Settings.SettingsMain.proxyBypassFilterTip": "Use ',' as a separator, and '*.' as a wildcard for subdomains",
"components.Settings.SettingsMain.proxyBypassLocalAddresses": "Bypass Proxy for Local Addresses",
"components.Settings.SettingsMain.proxyEnabled": "HTTP(S) Proxy",
"components.Settings.SettingsMain.proxyHostname": "Proxy Hostname",
"components.Settings.SettingsMain.proxyPassword": "Proxy Password",
"components.Settings.SettingsMain.proxyPort": "Proxy Port",
"components.Settings.SettingsMain.proxySsl": "Use SSL For Proxy",
"components.Settings.SettingsMain.proxyUser": "Proxy Username",
"components.Settings.SettingsMain.streamingRegion": "Streaming Region",
"components.Settings.SettingsMain.streamingRegionTip": "Show streaming sites by regional availability",
"components.Settings.SettingsMain.toastApiKeyFailure": "Something went wrong while generating a new API key.",
"components.Settings.SettingsMain.toastApiKeySuccess": "New API key generated successfully!",
"components.Settings.SettingsMain.toastSettingsFailure": "Something went wrong while saving settings.",
"components.Settings.SettingsMain.toastSettingsSuccess": "Settings saved successfully!",
"components.Settings.SettingsMain.trustProxy": "Enable Proxy Support",
"components.Settings.SettingsMain.trustProxyTip": "Allow Jellyseerr to correctly register client IP addresses behind a proxy",
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.SettingsNetwork.advancedNetworkSettings": "Advanced Network Settings",
"components.Settings.SettingsNetwork.csrfProtection": "Enable CSRF Protection",
"components.Settings.SettingsNetwork.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
"components.Settings.SettingsNetwork.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
"components.Settings.SettingsNetwork.dnsServers": "Custom DNS Servers",
"components.Settings.SettingsNetwork.dnsServersTip": "Comma-separated list of custom DNS servers, e.g. \"1.1.1.1,[2606:4700:4700::1111]\"",
"components.Settings.SettingsNetwork.docs": "documentation",
"components.Settings.SettingsNetwork.forceIpv4First": "Force IPv4 Resolution First",
"components.Settings.SettingsNetwork.forceIpv4FirstTip": "Force Jellyseerr to resolve IPv4 addresses first instead of IPv6",
"components.Settings.SettingsNetwork.network": "Network",
"components.Settings.SettingsNetwork.networkDisclaimer": "Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.",
"components.Settings.SettingsNetwork.networksettings": "Network Settings",
"components.Settings.SettingsNetwork.networksettingsDescription": "Configure network settings for your Jellyseerr instance.",
"components.Settings.SettingsNetwork.proxyBypassFilter": "Proxy Ignored Addresses",
"components.Settings.SettingsNetwork.proxyBypassFilterTip": "Use ',' as a separator, and '*.' as a wildcard for subdomains",
"components.Settings.SettingsNetwork.proxyBypassLocalAddresses": "Bypass Proxy for Local Addresses",
"components.Settings.SettingsNetwork.proxyEnabled": "HTTP(S) Proxy",
"components.Settings.SettingsNetwork.proxyHostname": "Proxy Hostname",
"components.Settings.SettingsNetwork.proxyPassword": "Proxy Password",
"components.Settings.SettingsNetwork.proxyPort": "Proxy Port",
"components.Settings.SettingsNetwork.proxySsl": "Use SSL For Proxy",
"components.Settings.SettingsNetwork.proxyUser": "Proxy Username",
"components.Settings.SettingsNetwork.toastSettingsFailure": "Something went wrong while saving settings.",
"components.Settings.SettingsNetwork.toastSettingsSuccess": "Settings saved successfully!",
"components.Settings.SettingsNetwork.trustProxy": "Enable Proxy Support",
"components.Settings.SettingsNetwork.trustProxyTip": "Allow Jellyseerr to correctly register client IP addresses behind a proxy",
"components.Settings.SettingsNetwork.validationProxyPort": "You must provide a valid port",
"components.Settings.SettingsMain.validationProxyPort": "You must provide a valid port",
"components.Settings.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
"components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users",
@@ -992,6 +973,7 @@
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
"components.Settings.SettingsUsers.users": "Users",
"components.Settings.SonarrModal.add": "Add Server",
"components.Settings.SonarrModal.addrule": "New Override Rule",
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
"components.Settings.SonarrModal.animeTags": "Anime Tags",
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
@@ -1014,6 +996,7 @@
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.SonarrModal.notagoptions": "No tags.",
"components.Settings.SonarrModal.overrideRules": "Override Rules",
"components.Settings.SonarrModal.port": "Port",
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
"components.Settings.SonarrModal.rootfolder": "Root Folder",
@@ -1050,18 +1033,11 @@
"components.Settings.activeProfile": "Active Profile",
"components.Settings.addradarr": "Add Radarr Server",
"components.Settings.address": "Address",
"components.Settings.addrule": "New Override Rule",
"components.Settings.addsonarr": "Add Sonarr Server",
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
"components.Settings.apiKey": "API key",
"components.Settings.blacklistedTagImportInstructions": "Paste blacklist tag configuration below.",
"components.Settings.blacklistedTagImportTitle": "Import Blacklisted Tag Configuration",
"components.Settings.blacklistedTagsText": "Blacklisted Tags",
"components.Settings.cancelscan": "Cancel Scan",
"components.Settings.clearBlacklistedTagsConfirm": "Are you sure you want to clear the blacklisted tags?",
"components.Settings.copyBlacklistedTags": "Copied blacklisted tags to clipboard.",
"components.Settings.copyBlacklistedTagsEmpty": "Nothing to copy",
"components.Settings.copyBlacklistedTagsTip": "Copy blacklisted tag configuration",
"components.Settings.copied": "Copied API key to clipboard.",
"components.Settings.currentlibrary": "Current Library: {name}",
"components.Settings.default": "Default",
"components.Settings.default4k": "Default 4K",
@@ -1072,8 +1048,6 @@
"components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior",
"components.Settings.externalUrl": "External URL",
"components.Settings.hostname": "Hostname or IP Address",
"components.Settings.importBlacklistedTagsTip": "Import blacklisted tag configuration",
"components.Settings.invalidKeyword": "{keywordId} is not a TMDB keyword.",
"components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.",
"components.Settings.is4k": "4K",
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
@@ -1100,23 +1074,17 @@
"components.Settings.menuJellyfinSettings": "{mediaServerName}",
"components.Settings.menuJobs": "Jobs & Cache",
"components.Settings.menuLogs": "Logs",
"components.Settings.menuNetwork": "Network",
"components.Settings.menuNotifications": "Notifications",
"components.Settings.menuPlexSettings": "Plex",
"components.Settings.menuServices": "Services",
"components.Settings.menuUsers": "Users",
"components.Settings.no": "No",
"components.Settings.noDefault4kServer": "A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.",
"components.Settings.noDefaultNon4kServer": "If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.",
"components.Settings.noDefaultServer": "At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.",
"components.Settings.noSpecialCharacters": "Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.",
"components.Settings.nooptions": "No results.",
"components.Settings.notificationAgentSettingsDescription": "Configure and enable notification agents.",
"components.Settings.notifications": "Notifications",
"components.Settings.notificationsettings": "Notification Settings",
"components.Settings.notrunning": "Not Running",
"components.Settings.overrideRules": "Override Rules",
"components.Settings.overrideRulesDescription": "Override rules allow you to specify properties that will be replaced if a request matches the rule.",
"components.Settings.plex": "Plex",
"components.Settings.plexlibraries": "Plex Libraries",
"components.Settings.plexlibrariesDescription": "The libraries Jellyseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.",
@@ -1130,7 +1098,6 @@
"components.Settings.scan": "Sync Libraries",
"components.Settings.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
"components.Settings.scanning": "Syncing…",
"components.Settings.searchKeywords": "Search keywords…",
"components.Settings.serverLocal": "local",
"components.Settings.serverRemote": "remote",
"components.Settings.serverSecure": "secure",
@@ -1144,7 +1111,6 @@
"components.Settings.sonarrsettings": "Sonarr Settings",
"components.Settings.ssl": "SSL",
"components.Settings.startscan": "Start Scan",
"components.Settings.starttyping": "Starting typing to search.",
"components.Settings.syncJellyfin": "Sync Libraries",
"components.Settings.syncing": "Syncing",
"components.Settings.tautulliApiKey": "API Key",
@@ -1168,12 +1134,10 @@
"components.Settings.validationUrlBaseLeadingSlash": "URL base must have a leading slash",
"components.Settings.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash",
"components.Settings.validationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.valueRequired": "You must provide a value.",
"components.Settings.webAppUrl": "<WebAppLink>Web App</WebAppLink> URL",
"components.Settings.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app",
"components.Settings.webhook": "Webhook",
"components.Settings.webpush": "Web Push",
"components.Settings.yes": "Yes",
"components.Setup.back": "Go back",
"components.Setup.configemby": "Configure Emby",
"components.Setup.configjellyfin": "Configure Jellyfin",
@@ -1186,7 +1150,7 @@
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
"components.Setup.servertype": "Choose Server Type",
"components.Setup.setup": "Setup",
"components.Setup.signin": "Sign in to your account",
"components.Setup.signin": "Sign In",
"components.Setup.signinMessage": "Get started by signing in",
"components.Setup.signinWithEmby": "Enter your Emby details",
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
@@ -1307,17 +1271,6 @@
"components.UserProfile.ProfileHeader.profile": "View Profile",
"components.UserProfile.ProfileHeader.settings": "Edit Settings",
"components.UserProfile.ProfileHeader.userid": "User ID: {userid}",
"components.UserProfile.UserSettings.LinkJellyfinModal.description": "Enter your {mediaServerName} credentials to link your account with {applicationName}.",
"components.UserProfile.UserSettings.LinkJellyfinModal.errorExists": "This account is already linked to a {applicationName} user",
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnauthorized": "Unable to connect to {mediaServerName} using your credentials",
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnknown": "An unknown error occurred",
"components.UserProfile.UserSettings.LinkJellyfinModal.password": "Password",
"components.UserProfile.UserSettings.LinkJellyfinModal.passwordRequired": "You must provide a password",
"components.UserProfile.UserSettings.LinkJellyfinModal.save": "Link",
"components.UserProfile.UserSettings.LinkJellyfinModal.saving": "Adding…",
"components.UserProfile.UserSettings.LinkJellyfinModal.title": "Link {mediaServerName} Account",
"components.UserProfile.UserSettings.LinkJellyfinModal.username": "Username",
"components.UserProfile.UserSettings.LinkJellyfinModal.usernameRequired": "You must provide a username",
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
@@ -1358,14 +1311,6 @@
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
"components.UserProfile.UserSettings.UserGeneralSettings.validationemailformat": "Valid email required",
"components.UserProfile.UserSettings.UserGeneralSettings.validationemailrequired": "Email required",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.deleteFailed": "Unable to delete linked account.",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.errorUnknown": "An unknown error occurred",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccounts": "Linked Accounts",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccountsHint": "These external accounts are linked to your {applicationName} account.",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.noLinkedAccounts": "You do not have any external accounts linked to your account.",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.noPermissionDescription": "You do not have permission to modify this user's linked accounts.",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorExists": "This account is already linked to a Plex user",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorUnauthorized": "Unable to connect to Plex using your credentials",
"components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default",
"components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID",
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your user account",
@@ -1428,7 +1373,6 @@
"components.UserProfile.UserSettings.UserPermissions.unauthorizedDescription": "You cannot modify your own permissions.",
"components.UserProfile.UserSettings.menuChangePass": "Password",
"components.UserProfile.UserSettings.menuGeneralSettings": "General",
"components.UserProfile.UserSettings.menuLinkedAccounts": "Linked Accounts",
"components.UserProfile.UserSettings.menuNotifications": "Notifications",
"components.UserProfile.UserSettings.menuPermissions": "Permissions",
"components.UserProfile.UserSettings.unauthorizedDescription": "You do not have permission to modify this user's settings.",

Some files were not shown because too many files have changed in this diff Show More