Compare commits

...

65 Commits

Author SHA1 Message Date
Brandon Cohen
3369adfb9a fix: modified all instances of request button show state 2023-10-02 13:18:34 -04:00
Brandon Cohen
76f57550fa feat: added the ability to request 4k media on the titlecard 2023-09-30 02:15:50 -04:00
Brandon Cohen
7bdd25e5a4 feat: standard series type selector (#3628)
* feat: added a standard series type selector

* fix: moved series type property to correct interface
2023-09-27 12:38:49 +00:00
Fallenbagel
f6286359cf feat: select default seriesType for anime (#3627)
* feat: select default seriesType for anime

Added flexibility to set default anime series type in service settings. Now you can choose
'standard' for anime if you prefer it, making it easier to use features like searching for season
packs on Sonarr.

fix #3626

* feat: extracted translations
2023-09-27 14:02:42 +09:00
Ryan Cohen
92507359b4 feat: add ko language (#3619) 2023-09-19 10:29:53 +09:00
Ryan Cohen
c484810f96 feat: add tooltips to tautulli avatars (#3601) 2023-08-31 08:35:45 +09:00
Jean Beauchamp
0c39057ca5 feat: add Peacock to Network Slider (#3545) 2023-08-25 10:54:42 +04:00
Weblate (bot)
e62a078298 feat: translations update from Hosted Weblate (#3258)
* feat(lang): translated using Weblate (Korean)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 11.2% (139 of 1233 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 11.3% (139 of 1226 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 7.8% (96 of 1226 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 7.4% (91 of 1226 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 1.7% (21 of 1226 strings)

feat(lang): added translation using Weblate (Korean)

Co-authored-by: Developer J <jshsakura@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: 김상구 (Studio) <spair0039@gmail.com>
Co-authored-by: 최효근 <gyrms7532@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ko/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1233 of 1233 strings)

Co-authored-by: BeardedWatermelon <periklis.karantonis@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/el/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Serbian)

Currently translated at 49.7% (608 of 1222 strings)

Co-authored-by: Dzonkins <nikoladjordjevic.ns@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sr/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1228 of 1228 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1222 of 1222 strings)

Co-authored-by: Angel <adelpozoman@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Romanian)

Currently translated at 33.0% (408 of 1234 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 29.7% (367 of 1234 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 27.9% (345 of 1234 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 27.8% (344 of 1233 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 27.6% (339 of 1226 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 27.4% (337 of 1226 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 22.8% (279 of 1223 strings)

Co-authored-by: Bunduc Dragos <bunduc.dragos@gmail.com>
Co-authored-by: DragoPrime <emperordrago@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ro/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 87.4% (1069 of 1223 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kirill Zhukov <siper13@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ru/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1222 of 1222 strings)

Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Emil Nymann <ens@hiper.dk>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/da/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Hungarian)

Currently translated at 86.3% (1055 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ZsiGiT <zsigit@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hu/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Dutch)

Currently translated at 99.4% (1226 of 1233 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1222 of 1222 strings)

Co-authored-by: Bas <bashankamp+weblate@gmail.com>
Co-authored-by: COTMO <moermantom1@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (1229 of 1233 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1228 of 1228 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 99.2% (1213 of 1222 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 99.1% (1212 of 1222 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 99.1% (1212 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mateus <mateusbernardo@protonmail.com>
Co-authored-by: Rafael Vieira <rafaelvieiras@pm.me>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Czech)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Czech)

Currently translated at 99.7% (1223 of 1226 strings)

feat(lang): translated using Weblate (Czech)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Czech)

Currently translated at 96.4% (1179 of 1222 strings)

feat(lang): translated using Weblate (Czech)

Currently translated at 89.1% (1090 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marek <marek@pavelka.xyz>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/cs/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1233 of 1233 strings)

Co-authored-by: Fhd-pro <juve.11@msn.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ar/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (1222 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_PT/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (German)

Currently translated at 99.9% (1233 of 1234 strings)

feat(lang): translated using Weblate (German)

Currently translated at 99.5% (1228 of 1234 strings)

feat(lang): translated using Weblate (German)

Currently translated at 99.5% (1227 of 1233 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (German)

Currently translated at 95.9% (1172 of 1222 strings)

feat(lang): translated using Weblate (German)

Currently translated at 95.9% (1172 of 1222 strings)

feat(lang): translated using Weblate (German)

Currently translated at 94.7% (1158 of 1222 strings)

Co-authored-by: Ben <ben.david.wallner@gmail.com>
Co-authored-by: Furkan Çakar <cakar.55.furkan@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Juli <snowjuli@protonmail.com>
Co-authored-by: Leo Schultheiss <leoschultheiss@yahoo.de>
Co-authored-by: inkarnation <94744834+inkarnation@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 99.6% (1229 of 1233 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1228 of 1228 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 90.0% (1104 of 1226 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 90.0% (1101 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Topfield99 <timmiesonne@live.se>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Lithuanian)

Currently translated at 58.7% (725 of 1233 strings)

feat(lang): translated using Weblate (Lithuanian)

Currently translated at 58.6% (719 of 1226 strings)

feat(lang): translated using Weblate (Lithuanian)

Currently translated at 51.0% (624 of 1222 strings)

feat(lang): translated using Weblate (Lithuanian)

Currently translated at 43.9% (537 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: PovilasID <povilas.sidaravicius@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/lt/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 99.5% (1217 of 1223 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 99.4% (1216 of 1223 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 98.6% (1207 of 1223 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 97.2% (1189 of 1223 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 94.3% (1154 of 1223 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Maite Guix <maite.guix@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ca/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Croatian)

Currently translated at 89.9% (1103 of 1226 strings)

Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hr/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Italian)

Currently translated at 92.2% (1138 of 1233 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 88.5% (1092 of 1233 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 85.8% (1058 of 1233 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 86.0% (1052 of 1223 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 83.2% (1017 of 1222 strings)

Co-authored-by: Francesco <francy.ammirati@hotmail.com>
Co-authored-by: Gian Marco Cinalli <gm.cinalli@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mirco Cau <mircocau@gmail.com>
Co-authored-by: eggermn <egger.mn@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.9% (1225 of 1226 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.9% (1225 of 1226 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.8% (1224 of 1226 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.9% (1223 of 1224 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 98.0% (1198 of 1222 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 96.7% (1182 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jassy lin <linjiaxinme@gmail.com>
Co-authored-by: anpplex <anpplex@gmail.com>
Co-authored-by: kx <yoboy.rox@gmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hans/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.9% (1225 of 1226 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.5% (1216 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.5% (1216 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.5% (1216 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1222 of 1222 strings)

Co-authored-by: Hordo <hordocast@mailo.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mathieu <math_du_88@yahoo.fr>
Co-authored-by: Maxent <rouaultmaxent@gmail.com>
Co-authored-by: Rémi Guerrero <remidu34070@hotmail.fr>
Co-authored-by: Sulli <susu.leduc@gmail.com>
Co-authored-by: Symness <simon@frayssines.fr>
Co-authored-by: Valentin <droidente@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Chinese (Traditional))

Currently translated at 99.7% (1219 of 1222 strings)

feat(lang): translated using Weblate (Chinese (Traditional))

Currently translated at 89.6% (1095 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: 주서현 <adan.89lion@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/
Translation: Overseerr/Overseerr Frontend

---------

Co-authored-by: Developer J <jshsakura@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: 김상구 (Studio) <spair0039@gmail.com>
Co-authored-by: 최효근 <gyrms7532@gmail.com>
Co-authored-by: BeardedWatermelon <periklis.karantonis@gmail.com>
Co-authored-by: Dzonkins <nikoladjordjevic.ns@gmail.com>
Co-authored-by: Angel <adelpozoman@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Bunduc Dragos <bunduc.dragos@gmail.com>
Co-authored-by: DragoPrime <emperordrago@gmail.com>
Co-authored-by: Kirill Zhukov <siper13@gmail.com>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Emil Nymann <ens@hiper.dk>
Co-authored-by: ZsiGiT <zsigit@gmail.com>
Co-authored-by: Bas <bashankamp+weblate@gmail.com>
Co-authored-by: COTMO <moermantom1@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Mateus <mateusbernardo@protonmail.com>
Co-authored-by: Rafael Vieira <rafaelvieiras@pm.me>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Co-authored-by: Marek <marek@pavelka.xyz>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Co-authored-by: Fhd-pro <juve.11@msn.com>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: Ben <ben.david.wallner@gmail.com>
Co-authored-by: Furkan Çakar <cakar.55.furkan@gmail.com>
Co-authored-by: Juli <snowjuli@protonmail.com>
Co-authored-by: Leo Schultheiss <leoschultheiss@yahoo.de>
Co-authored-by: inkarnation <94744834+inkarnation@users.noreply.github.com>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Topfield99 <timmiesonne@live.se>
Co-authored-by: PovilasID <povilas.sidaravicius@gmail.com>
Co-authored-by: Maite Guix <maite.guix@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com>
Co-authored-by: Francesco <francy.ammirati@hotmail.com>
Co-authored-by: Gian Marco Cinalli <gm.cinalli@gmail.com>
Co-authored-by: Mirco Cau <mircocau@gmail.com>
Co-authored-by: eggermn <egger.mn@gmail.com>
Co-authored-by: Jassy lin <linjiaxinme@gmail.com>
Co-authored-by: anpplex <anpplex@gmail.com>
Co-authored-by: kx <yoboy.rox@gmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Co-authored-by: Hordo <hordocast@mailo.com>
Co-authored-by: Mathieu <math_du_88@yahoo.fr>
Co-authored-by: Maxent <rouaultmaxent@gmail.com>
Co-authored-by: Rémi Guerrero <remidu34070@hotmail.fr>
Co-authored-by: Sulli <susu.leduc@gmail.com>
Co-authored-by: Symness <simon@frayssines.fr>
Co-authored-by: Valentin <droidente@gmail.com>
Co-authored-by: 주서현 <adan.89lion@gmail.com>
2023-08-24 20:14:19 +09:00
Ryan Cohen
317110855e ci: remove docker caching to save space after build (#3574) 2023-08-06 16:15:03 +09:00
TheCatLady
f7b4dfcac4 fix(tautulli): only test connection if hostname is defined (#3573) 2023-08-05 08:26:03 +09:00
allcontributors[bot]
a686d31e4d docs: add nemchik as a contributor for code (#3565) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-07-28 23:42:29 +09:00
Eric Nemchik
cb63bf217b fix: Include all defaults in payload (#3538)
* fix: Include all defaults in payload

* style(src/components/settings/notifications/notificationswebhook/index.tsx): prettier format

format changes from previous commit using prettier. line length requirement now met.

* fix(server/lib/settings.ts): update default settings for first install
2023-07-28 23:41:52 +09:00
allcontributors[bot]
46e21c4e3e docs: add marcofaggian as a contributor for code (#3563) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-07-28 20:52:03 +09:00
Marco Faggian
b4191f9c65 feat(rating): added IMDB Radarr proxy (#3496)
* feat(rating): added imdb radarr proxy

Signed-off-by: marcofaggian <m@marcofaggian.com>

* refactor(rating/imdb): rm export unused interfaces

Signed-off-by: marcofaggian <m@marcofaggian.com>

* docs(rating/imdb): rt to imdb

Signed-off-by: marcofaggian <m@marcofaggian.com>

* refactor(rating/imdb): specified error message

Signed-off-by: marcofaggian <m@marcofaggian.com>

* refactor(rating/imdb): rm line break

Signed-off-by: marcofaggian <m@marcofaggian.com>

* refactor(rating): conform to types patter

Signed-off-by: marcofaggian <m@marcofaggian.com>

* chore(rating/imdb): added line to translation file

Signed-off-by: marcofaggian <m@marcofaggian.com>

* feat(rating/imdb): ratings to ratingscombined

Signed-off-by: marcofaggian <m@marcofaggian.com>

* fix(rating/imdb): reinstating ratings route

Signed-off-by: marcofaggian <m@marcofaggian.com>

* docs(ratings): openapi ratings

Signed-off-by: marcofaggian <m@marcofaggian.com>

* chore(ratings): undo openapi ratings apex

Signed-off-by: marcofaggian <m@marcofaggian.com>

---------

Signed-off-by: marcofaggian <m@marcofaggian.com>
2023-07-28 20:51:19 +09:00
Brandon Cohen
83b008c839 fix: handle issue causing incorrect media to change to unknown (#3516)
* fix: handle issue causing incorrect media to change back to unknown

* fix: prevent start if plex client is unavailable

* fix: initialize radarr and sonarr clients before the scan starts

* fix: compensate for multiple *arr servers

* fix: added a more reliable season lookup

* refactor: modified tuples to increase code readability
2023-07-24 15:33:12 +09:00
allcontributors[bot]
68c7b3650e docs: add scorp200 as a contributor for code (#3555) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-07-18 05:17:07 +00:00
Anton K. (ai Doge)
2816c66300 fix: resolved user access check issue (#3551)
* fix: importing friends

update checkUserAccess to use getUsers

* refactor(server/api/plextv.ts): clean up

removed unused getFriends function, and its interface.
renamed friends variable.
2023-07-18 01:03:52 -04:00
TheCatLady
01de972a8f fix(statusbadge): handle missing season/episode number (#3526) 2023-06-29 12:34:10 -04:00
Brandon Cohen
a761b7dd35 fix: resolved issue with create slider causing incorrect form submission (#3514) 2023-06-21 17:18:50 +00:00
Brandon Cohen
d0836ce0ef fix: improved handling of edge case that could cause availability sync to fail (#3497) 2023-06-14 14:43:08 +09:00
Ryan Cohen
2c3f533076 fix: adjust the plex watchlist sync schedule to have fuzziness (#3502)
also fixes the schedule making it uneditable
2023-06-13 23:15:54 +09:00
Brandon Cohen
c1a47bd9de fix(ui): corrected issues icon color (#3498) 2023-06-12 17:35:01 +09:00
allcontributors[bot]
24d94ef6fd docs: add Fallenbagel as a contributor for code (#3493) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-06-11 10:15:55 +09:00
Fallenbagel
04fbd00d4a fix: fixes RT ratings for tv shows (#3492)
fix #3491
2023-06-11 10:14:14 +09:00
allcontributors[bot]
21231186d1 docs: add andrew-kennedy as a contributor for code (#3489) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-06-09 23:20:02 +00:00
Andrew Kennedy
48f76662d5 fix: make a (shallow) copy of radarr/sonarr tags into a request before adding user tags (#3485)
* Make a (shallow) copy of radarr/sonarr tags into a request before adding user tags

* Undo random formatting changes

* more undoing formatting changes

* Fix undefined case.

* Prettier format
2023-06-09 18:46:04 -04:00
allcontributors[bot]
df332cec84 docs: add SalmanTariq as a contributor for code (#3478)
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-06-01 00:24:05 +00:00
Salman Tariq
d7fa35e066 fix(genreselector): fix searching in Genre filter (#3468) 2023-05-31 21:53:50 +00:00
Ryan Cohen
f33eb862fd chore: update codeowners (#3474) [skip ci] 2023-05-29 11:36:26 +09:00
allcontributors[bot]
0a007ca805 docs: add IzaacJ as a contributor for code (#3473) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-05-29 11:32:08 +09:00
Izaac Brånn
24f268b6cb feat: auto tagging requested media with username (#3338)
* feat: auto tagging requested media with username

Relating to discussion: https://github.com/sct/overseerr/discussions/3313

Adding an option to the Radarr and Sonarr service to enable automatic tagging
with the username requesting the media.

Current format, to reduce tag clutter if a user changes displayname:

`[user.id] - [user.displayName]`

* fix: modified new secondary tip language

---------

Co-authored-by: Brandon Cohen <brandon@z3hn.dev>
2023-05-29 02:29:36 +00:00
allcontributors[bot]
b8e3c07c47 docs: add SMores as a contributor for code (#3455) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-05-13 09:35:51 +09:00
Shane Friedman
aa84977680 feat(discover): support filtering by tmdb user vote count on discover page (#3407) 2023-05-13 00:23:14 +00:00
Brandon Cohen
e051b1dfea fix: correctly load series fallback modal with sonarr v4 (#3451) 2023-05-11 04:43:11 +00:00
Brandon Cohen
c27f96096a fix: lock body scroll when using webkit (#3399) 2023-05-11 04:27:45 +00:00
Brandon Cohen
4bd87647d0 fix: corrected initial fallback data load on details page (#3395) 2023-05-11 04:16:50 +00:00
Brandon Cohen
c1e10338c1 refactor: pull to refresh (#3391)
* refactor: decoupled PTR by removing import and creating new touch logic

* fix: overscroll behavior on mobile is now prevented on the y axis

* feat: added shadow effects to icon

* fix: modified cypress test

* fix: added better scroll lock functionality

* fix: hide icon if scroll value is negative

* fix: changed to allow usage on all touch devices
2023-05-11 11:59:12 +09:00
allcontributors[bot]
cd1cacad55 docs: add Zebebles as a contributor for code (#3453) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-05-11 10:45:54 +09:00
Zeb Muller
ac77b037d5 fix: error deleting users with over 1000 requests (#3376)
Break-up request removal into groups of 1000 requests to be removed at a time.
2023-05-11 10:45:23 +09:00
allcontributors[bot]
10eb69a7dc docs: add Alexays as a contributor for code (#3452) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-05-11 09:59:15 +09:00
Alex
70b1540ae2 fix: handle search results with collections (#3393)
* feat: handle search collection

* Update server/utils/typeHelpers.ts

Co-authored-by: Danshil Kokil Mungur <danshil.mungur@gmail.com>

* fix: modified title card to show collection instead of movies

---------

Co-authored-by: Danshil Kokil Mungur <danshil.mungur@gmail.com>
Co-authored-by: Brandon <cohbrandon@gmail.com>
2023-05-11 09:58:16 +09:00
Brandon Cohen
7522aa3174 fix: availability sync file detection (#3371)
* fix: added extra check for unmonitored movies in radarr

* feat: created new radarr/sonarr routes  to grab existing series data

* refactor: updated job routes to check by external service id

* fix: season check will now also look at episode file count
2023-05-11 09:36:12 +09:00
Brandon Cohen
77a33cb74d fix(ui): corrected mobile menu spacing in collection details (#3432) 2023-05-04 22:08:22 +04:00
Brandon Cohen
a4d07f5afa fix(ui): corrected default badge hover opacity (#3369) 2023-03-02 08:21:55 -05:00
renovate[bot]
f5191aded6 fix(deps): update all non-major dependencies (#3223)
* chore(deps): update all non-major dependencies

* fix: resolve same express-session types for connect-typeorm

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: sct <ryan@sct.dev>
2023-03-01 11:59:06 -05:00
Brandon Cohen
2520d8f739 feat: adds streaming services custom slider (#3361)
* feat: adding streaming services as a slider is now an option

* fix: truncated slider titles on mobile
2023-02-28 13:36:28 +09:00
allcontributors[bot]
04980f93ab docs: add jariz as a contributor for code (#3357) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-02-24 19:47:36 +09:00
allcontributors[bot]
2a3213d706 docs: add Nimelrian as a contributor for code (#3356) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-02-24 19:46:46 +09:00
Brandon Cohen
c36a4ba2b8 fix: logger was set to info for the wrong logs (#3354) 2023-02-24 07:29:42 +00:00
Brandon Cohen
ae3818304b feat: availability sync rework (#3219)
* feat: add availability synchronization job

fix #377

* fix: feedback on PR

* perf: use pagination for Media Availability Synchronization job

The original approach loaded all media items from the database at once. With large libraries, this
could lead to performance issues. We're now using a paginated approach with a page size of 50.

* feat: updated the availability sync to work with 4k

* fix: corrected detection of media in plex

* refactor: code cleanup and minimized unnecessary calls

* fix: if media is not found, media check will continue

* fix: if non-4k or 4k show media is updated, seasons and request is now properly updated

* refactor: consolidated media updater into one function

* fix: season requests are now removed if season has been deleted

* refactor: removed joincolumn

* fix: makes sure we will always check radarr/sonarr to see if media exists

* fix: media will now only be updated to unavailable and deletion will be prevented

* fix: changed types in Media entity

* fix: prevent season deletion in preference of setting season to unknown

---------

Co-authored-by: Jari Zwarts <jari@oberon.nl>
Co-authored-by: Sebastian Kappen <sebastian@kappen.dev>
2023-02-24 05:28:22 +00:00
Danshil Kokil Mungur
b3882de893 fix(ui): hide search bar behind slideover when opened (#3348) 2023-02-24 02:03:01 +00:00
Danshil Kokil Mungur
af880a6c83 fix(watchlist): correctly load more than 20 watchlist items (#3351)
* fix(discover): correctly load additional watchlist items pages

* chore(discover): remove unused params types
2023-02-24 00:40:01 +00:00
Danshil Kokil Mungur
eb5502a16f fix(ui): prevent title cards from flickering when quickly hovering across them (#3349)
* fix(ui): remove opacity classes from transition enter and leave props

The flickering was caused by the opacity classes in the `leave` prop to take effect as the
transition ends; when the `leaveTo` prop classes are no longer applied, but the `leave` prop classes
are still applied.

* fix(ui): resolve transition issues for all components

1. Remove opacity classes from `enter` and `leave` props
2. Fix some class name typos
3. Remove transform classes since those are automatically applied as from TailwindCSS v3.0
4. Narrow down `transition` classes to only the properties being transitioned in Transition components
2023-02-24 00:27:26 +09:00
Danshil Kokil Mungur
50f06dabbf fix(ui): hide mini status badge if non-4K media status is unknown (#3346) 2023-02-20 10:54:06 +09:00
Brandon Cohen
1e2c6f46ab fix: added a refresh interval if download status is in progress (#3275)
* fix: added a refresh interval if download status is in progress

* refactor: switched to a function instead of useEffect

* feat: added editable download sync schedule
2023-02-15 19:16:13 +04:00
Owen Voke
dd1378cef5 chore(api): update descriptions for API endpoints (#3341) 2023-02-15 00:40:51 +04:00
allcontributors[bot]
e684456bba docs: add owenvoke as a contributor for code (#3340) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-02-14 14:53:35 +09:00
Owen Voke
6bd3f015d6 fix: remove unnecessary parenthesis from api key generation (#3336) 2023-02-14 14:52:26 +09:00
allcontributors[bot]
2d97be0d6c docs: add lunks as a contributor for code (#3334) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-02-12 20:05:59 +09:00
Pedro Nascimento
966639df43 perf(imageproxy): do not set cookies to image proxy so CDNs can cache images (#3332)
CDNs such as Cloudflare bypass their cache if cookies are set in the response.
clearCookies
middleware removes the header before imageproxy serves the image.
2023-02-12 20:05:29 +09:00
Brandon Cohen
33e7691b94 feat: full title of download item on hover with tooltip (#3296)
Co-authored-by: Ryan Cohen <ryan@sct.dev>
2023-02-11 10:30:53 +00:00
Danshil Kokil Mungur
d7b83d22ce fix(build): increase threshold for amount of data to be fetched when SSR'ing (#3320)
Co-authored-by: Ryan Cohen <ryan@sct.dev>
2023-02-11 09:36:31 +00:00
Ryan Cohen
b6eac0f364 test: change custom keyword for slider creation (#3333) 2023-02-11 17:18:30 +09:00
Ryan Cohen
c93467b3ac fix(snapcraft): use the correct config folder for image cache (#3302) 2023-01-31 01:31:39 +09:00
Ryan Cohen
d954328911 fix(ui): correct range slider styling in chrome (#3299) 2023-01-29 20:32:25 +09:00
126 changed files with 9041 additions and 2511 deletions

View File

@@ -773,6 +773,132 @@
"contributions": [
"code"
]
},
{
"login": "lunks",
"name": "Pedro Nascimento",
"avatar_url": "https://avatars.githubusercontent.com/u/91118?v=4",
"profile": "http://twitter.com/lunks/",
"contributions": [
"code"
]
},
{
"login": "owenvoke",
"name": "Owen Voke",
"avatar_url": "https://avatars.githubusercontent.com/u/1899334?v=4",
"profile": "https://voke.dev",
"contributions": [
"code"
]
},
{
"login": "Nimelrian",
"name": "Sebastian K",
"avatar_url": "https://avatars.githubusercontent.com/u/8960836?v=4",
"profile": "https://github.com/Nimelrian",
"contributions": [
"code"
]
},
{
"login": "jariz",
"name": "jariz",
"avatar_url": "https://avatars.githubusercontent.com/u/1415847?v=4",
"profile": "https://github.com/jariz",
"contributions": [
"code"
]
},
{
"login": "Alexays",
"name": "Alex",
"avatar_url": "https://avatars.githubusercontent.com/u/13947260?v=4",
"profile": "https://arouillard.fr",
"contributions": [
"code"
]
},
{
"login": "Zebebles",
"name": "Zeb Muller",
"avatar_url": "https://avatars.githubusercontent.com/u/11425451?v=4",
"profile": "https://github.com/Zebebles",
"contributions": [
"code"
]
},
{
"login": "SMores",
"name": "Shane Friedman",
"avatar_url": "https://avatars.githubusercontent.com/u/5354254?v=4",
"profile": "http://smoores.dev",
"contributions": [
"code"
]
},
{
"login": "IzaacJ",
"name": "Izaac Brånn",
"avatar_url": "https://avatars.githubusercontent.com/u/711323?v=4",
"profile": "https://izaacj.me",
"contributions": [
"code"
]
},
{
"login": "SalmanTariq",
"name": "Salman Tariq",
"avatar_url": "https://avatars.githubusercontent.com/u/13284494?v=4",
"profile": "https://github.com/SalmanTariq",
"contributions": [
"code"
]
},
{
"login": "andrew-kennedy",
"name": "Andrew Kennedy",
"avatar_url": "https://avatars.githubusercontent.com/u/2387159?v=4",
"profile": "https://github.com/andrew-kennedy",
"contributions": [
"code"
]
},
{
"login": "Fallenbagel",
"name": "Fallenbagel",
"avatar_url": "https://avatars.githubusercontent.com/u/98979876?v=4",
"profile": "https://github.com/Fallenbagel",
"contributions": [
"code"
]
},
{
"login": "scorp200",
"name": "Anton K. (ai Doge)",
"avatar_url": "https://avatars.githubusercontent.com/u/9427639?v=4",
"profile": "http://aidoge.xyz",
"contributions": [
"code"
]
},
{
"login": "marcofaggian",
"name": "Marco Faggian",
"avatar_url": "https://avatars.githubusercontent.com/u/19221001?v=4",
"profile": "https://marcofaggian.com",
"contributions": [
"code"
]
},
{
"login": "nemchik",
"name": "Eric Nemchik",
"avatar_url": "https://avatars.githubusercontent.com/u/725456?v=4",
"profile": "http://nemchik.com/",
"contributions": [
"code"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
@@ -782,5 +908,6 @@
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": false,
"commitConvention": "angular"
"commitConvention": "angular",
"commitType": "docs"
}

10
.github/CODEOWNERS vendored
View File

@@ -1,10 +1,10 @@
# Global code ownership
* @sct @TheCatLady @danshilm
* @sct @TheCatLady @danshilm @OwsleyJr
# Documentation
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm
/*.md @TheCatLady @samwiseg0 @danshilm
/docs/ @TheCatLady @samwiseg0 @danshilm
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm @OwsleyJr
/*.md @TheCatLady @samwiseg0 @danshilm @OwsleyJr
/docs/ @TheCatLady @samwiseg0 @danshilm @OwsleyJr
# Snap-related files
/.github/workflows/snap.yaml @samwiseg0
@@ -12,4 +12,4 @@
# i18n locale files
/src/i18n/locale/ @sct @TheCatLady
/src/i18n/locale/en.json @sct @TheCatLady @danshilm
/src/i18n/locale/en.json @sct @TheCatLady @danshilm @OwsleyJr

View File

@@ -39,13 +39,6 @@ jobs:
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -71,15 +64,6 @@ jobs:
sctx/overseerr:${{ github.sha }}
ghcr.io/sct/overseerr:develop
ghcr.io/sct/overseerr:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- # Temporary fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
discord:
name: Send Discord Notification

View File

@@ -16,5 +16,8 @@
}
],
"editor.formatOnSave": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
"typescript.preferences.importModuleSpecifier": "non-relative",
"files.associations": {
"globals.css": "tailwindcss"
}
}

View File

@@ -11,7 +11,7 @@
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></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-84-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-98-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@@ -182,6 +182,24 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<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>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/lunks/"><img src="https://avatars.githubusercontent.com/u/91118?v=4?s=100" width="100px;" alt="Pedro Nascimento"/><br /><sub><b>Pedro Nascimento</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lunks" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://voke.dev"><img src="https://avatars.githubusercontent.com/u/1899334?v=4?s=100" width="100px;" alt="Owen Voke"/><br /><sub><b>Owen Voke</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=owenvoke" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nimelrian"><img src="https://avatars.githubusercontent.com/u/8960836?v=4?s=100" width="100px;" alt="Sebastian K"/><br /><sub><b>Sebastian K</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Nimelrian" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jariz"><img src="https://avatars.githubusercontent.com/u/1415847?v=4?s=100" width="100px;" alt="jariz"/><br /><sub><b>jariz</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jariz" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://arouillard.fr"><img src="https://avatars.githubusercontent.com/u/13947260?v=4?s=100" width="100px;" alt="Alex"/><br /><sub><b>Alex</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Alexays" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zebebles"><img src="https://avatars.githubusercontent.com/u/11425451?v=4?s=100" width="100px;" alt="Zeb Muller"/><br /><sub><b>Zeb Muller</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Zebebles" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://smoores.dev"><img src="https://avatars.githubusercontent.com/u/5354254?v=4?s=100" width="100px;" alt="Shane Friedman"/><br /><sub><b>Shane Friedman</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SMores" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -13,7 +13,7 @@ describe('Pull To Refresh', () => {
url: '/api/v1/*',
}).as('apiCall');
cy.get('.searchbar').swipe('bottom', [190, 400]);
cy.get('.searchbar').swipe('bottom', [190, 500]);
cy.wait('@apiCall').then((interception) => {
assert.isNotNull(

View File

@@ -96,7 +96,7 @@ describe('Discover Customization', () => {
.should('be.disabled');
cy.get('#data').clear();
cy.get('#data').type('time travel{enter}', { delay: 100 });
cy.get('#data').type('christmas{enter}', { delay: 100 });
// Confirming we have some results
cy.contains('.slider-header', sliderTitle)

View File

@@ -19,5 +19,6 @@ module.exports = {
},
experimental: {
scrollRestoration: true,
largePageDataBytes: 256000,
},
};

View File

@@ -3615,7 +3615,7 @@ paths:
$ref: '#/components/schemas/User'
/user/{userId}/requests:
get:
summary: Get user by ID
summary: Get requests for a specific user
description: |
Retrieves a user's requests in a JSON object.
tags:
@@ -3711,7 +3711,7 @@ paths:
example: false
/user/{userId}/watchlist:
get:
summary: Get user by ID
summary: Get the Plex watchlist for a specific user
description: |
Retrieves a user's Plex Watchlist in a JSON object.
tags:
@@ -4186,6 +4186,16 @@ paths:
schema:
type: number
example: 10
- in: query
name: voteCountGte
schema:
type: number
example: 7
- in: query
name: voteCountLte
schema:
type: number
example: 10
- in: query
name: watchRegion
schema:
@@ -4465,6 +4475,16 @@ paths:
schema:
type: number
example: 10
- in: query
name: voteCountGte
schema:
type: number
example: 7
- in: query
name: voteCountLte
schema:
type: number
example: 10
- in: query
name: watchRegion
schema:
@@ -5318,6 +5338,63 @@ paths:
audienceRating:
type: string
enum: ['Spilled', 'Upright']
/movie/{movieId}/ratingscombined:
get:
summary: Get RT and IMDB movie ratings combined
description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object.
tags:
- movies
parameters:
- in: path
name: movieId
required: true
schema:
type: number
example: 337401
responses:
'200':
description: Ratings returned
content:
application/json:
schema:
type: object
properties:
rt:
type: object
properties:
title:
type: string
example: Mulan
year:
type: number
example: 2020
url:
type: string
example: 'http://www.rottentomatoes.com/m/mulan_2020/'
criticsScore:
type: number
example: 85
criticsRating:
type: string
enum: ['Rotten', 'Fresh', 'Certified Fresh']
audienceScore:
type: number
example: 65
audienceRating:
type: string
enum: ['Spilled', 'Upright']
imdb:
type: object
properties:
title:
type: string
example: I am Legend
url:
type: string
example: 'https://www.imdb.com/title/tt0480249'
criticsScore:
type: number
example: 6.5
/tv/{tvId}:
get:
summary: Get TV details

View File

@@ -29,17 +29,17 @@
},
"license": "MIT",
"dependencies": {
"@formatjs/intl-displaynames": "6.2.3",
"@formatjs/intl-locale": "3.0.11",
"@formatjs/intl-pluralrules": "5.1.8",
"@formatjs/intl-displaynames": "6.2.6",
"@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.1.10",
"@formatjs/intl-utils": "3.8.4",
"@headlessui/react": "1.7.7",
"@heroicons/react": "2.0.13",
"@headlessui/react": "1.7.12",
"@heroicons/react": "2.0.16",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.22",
"ace-builds": "1.14.0",
"axios": "1.2.2",
"@tanem/react-nprogress": "5.0.30",
"ace-builds": "1.15.2",
"axios": "1.3.4",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
@@ -47,7 +47,7 @@
"cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.3",
"country-flag-icons": "1.5.5",
"cronstrue": "2.21.0",
"cronstrue": "2.23.0",
"csurf": "1.11.0",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
@@ -63,23 +63,22 @@
"next": "12.3.4",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
"node-schedule": "2.1.0",
"nodemailer": "6.8.0",
"openpgp": "5.5.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.1",
"openpgp": "5.7.0",
"plex-api": "5.3.2",
"pug": "3.0.2",
"pulltorefreshjs": "0.1.22",
"react": "18.2.0",
"react-ace": "10.1.0",
"react-animate-height": "2.1.2",
"react-aria": "3.22.0",
"react-aria": "3.23.0",
"react-dom": "18.2.0",
"react-intersection-observer": "9.4.1",
"react-intl": "6.2.5",
"react-markdown": "8.0.4",
"react-intersection-observer": "9.4.3",
"react-intl": "6.2.10",
"react-markdown": "8.0.5",
"react-popper-tooltip": "4.4.2",
"react-select": "5.7.0",
"react-spring": "9.6.1",
"react-spring": "9.7.1",
"react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1",
"react-truncate-markup": "5.1.2",
@@ -88,42 +87,41 @@
"secure-random-password": "0.2.3",
"semver": "7.3.8",
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.0",
"swr": "2.0.0",
"typeorm": "0.3.11",
"swagger-ui-express": "4.6.2",
"swr": "2.0.4",
"typeorm": "0.3.12",
"web-push": "3.5.0",
"winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1",
"xml2js": "0.4.23",
"yamljs": "0.3.0",
"yup": "0.32.11",
"zod": "3.20.2"
"zod": "3.20.6"
},
"devDependencies": {
"@babel/cli": "7.20.7",
"@commitlint/cli": "17.4.0",
"@commitlint/config-conventional": "17.4.0",
"@babel/cli": "7.21.0",
"@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4",
"@semantic-release/changelog": "6.0.2",
"@semantic-release/commit-analyzer": "9.0.2",
"@semantic-release/exec": "6.0.3",
"@semantic-release/git": "10.0.1",
"@tailwindcss/aspect-ratio": "0.4.2",
"@tailwindcss/forms": "0.5.3",
"@tailwindcss/typography": "0.5.8",
"@tailwindcss/typography": "0.5.9",
"@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.3",
"@types/country-flag-icons": "1.2.0",
"@types/csurf": "1.11.2",
"@types/email-templates": "8.0.4",
"@types/express": "4.17.15",
"@types/express-session": "1.17.5",
"@types/express": "4.17.17",
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/node": "17.0.36",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",
"@types/pulltorefreshjs": "0.1.5",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.13",
@@ -132,45 +130,46 @@
"@types/xml2js": "0.4.11",
"@types/yamljs": "0.2.31",
"@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "5.48.0",
"@typescript-eslint/parser": "5.48.0",
"@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.13",
"babel-plugin-react-intl": "8.2.25",
"babel-plugin-react-intl-auto": "3.3.0",
"commitizen": "4.2.6",
"commitizen": "4.3.0",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
"cypress": "12.3.0",
"cypress": "12.7.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.31.0",
"eslint": "8.35.0",
"eslint-config-next": "12.3.4",
"eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.3.9",
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-formatjs": "4.9.0",
"eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-no-relative-import-paths": "1.5.2",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.31.11",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"extract-react-intl-messages": "4.1.1",
"husky": "8.0.3",
"lint-staged": "13.1.0",
"lint-staged": "13.1.2",
"nodemon": "2.0.20",
"postcss": "8.4.20",
"prettier": "2.8.1",
"prettier-plugin-organize-imports": "3.2.1",
"prettier-plugin-tailwindcss": "0.2.1",
"postcss": "8.4.21",
"prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.3",
"semantic-release": "19.0.5",
"semantic-release-docker-buildx": "1.0.1",
"tailwindcss": "3.2.4",
"tailwindcss": "3.2.7",
"ts-node": "10.9.1",
"tsc-alias": "1.8.2",
"tsconfig-paths": "4.1.2",
"typescript": "4.9.4"
"typescript": "4.9.5"
},
"resolutions": {
"sqlite3/node-gyp": "8.4.1",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10"
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/express-session": "1.17.6"
},
"config": {
"commitizen": {

View File

@@ -82,21 +82,6 @@ interface ServerResponse {
};
}
interface FriendResponse {
MediaContainer: {
User: {
$: {
id: string;
title: string;
username: string;
email: string;
thumb: string;
};
Server?: ServerResponse[];
}[];
};
}
interface UsersResponse {
MediaContainer: {
User: {
@@ -234,19 +219,6 @@ class PlexTvAPI extends ExternalAPI {
}
}
public async getFriends(): Promise<FriendResponse> {
const response = await this.axios.get('/pms/friends/all', {
transformResponse: [],
responseType: 'text',
});
const parsedXml = (await xml2js.parseStringPromise(
response.data
)) as FriendResponse;
return parsedXml;
}
public async checkUserAccess(userId: number): Promise<boolean> {
const settings = getSettings();
@@ -255,9 +227,9 @@ class PlexTvAPI extends ExternalAPI {
throw new Error('Plex is not configured!');
}
const friends = await this.getFriends();
const usersResponse = await this.getUsers();
const users = friends.MediaContainer.User;
const users = usersResponse.MediaContainer.User;
const user = users.find((u) => parseInt(u.$.id) === userId);

View File

@@ -0,0 +1,195 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
type IMDBRadarrProxyResponse = IMDBMovie[];
interface IMDBMovie {
ImdbId: string;
Overview: string;
Title: string;
OriginalTitle: string;
TitleSlug: string;
Ratings: Rating[];
MovieRatings: MovieRatings;
Runtime: number;
Images: Image[];
Genres: string[];
Popularity: number;
Premier: string;
InCinema: string;
PhysicalRelease: any;
DigitalRelease: string;
Year: number;
AlternativeTitles: AlternativeTitle[];
Translations: Translation[];
Recommendations: Recommendation[];
Credits: Credits;
Studio: string;
YoutubeTrailerId: string;
Certifications: Certification[];
Status: any;
Collection: Collection;
OriginalLanguage: string;
Homepage: string;
TmdbId: number;
}
interface Rating {
Count: number;
Value: number;
Origin: string;
Type: string;
}
interface MovieRatings {
Tmdb: Tmdb;
Imdb: Imdb;
Metacritic: Metacritic;
RottenTomatoes: RottenTomatoes;
}
interface Tmdb {
Count: number;
Value: number;
Type: string;
}
interface Imdb {
Count: number;
Value: number;
Type: string;
}
interface Metacritic {
Count: number;
Value: number;
Type: string;
}
interface RottenTomatoes {
Count: number;
Value: number;
Type: string;
}
interface Image {
CoverType: string;
Url: string;
}
interface AlternativeTitle {
Title: string;
Type: string;
Language: string;
}
interface Translation {
Title: string;
Overview: string;
Language: string;
}
interface Recommendation {
TmdbId: number;
Title: string;
}
interface Credits {
Cast: Cast[];
Crew: Crew[];
}
interface Cast {
Name: string;
Order: number;
Character: string;
TmdbId: number;
CreditId: string;
Images: Image2[];
}
interface Image2 {
CoverType: string;
Url: string;
}
interface Crew {
Name: string;
Job: string;
Department: string;
TmdbId: number;
CreditId: string;
Images: Image3[];
}
interface Image3 {
CoverType: string;
Url: string;
}
interface Certification {
Country: string;
Certification: string;
}
interface Collection {
Name: string;
Images: any;
Overview: any;
Translations: any;
Parts: any;
TmdbId: number;
}
export interface IMDBRating {
title: string;
url: string;
criticsScore: number;
}
/**
* This is a best-effort API. The IMDB API is technically
* private and getting access costs money/requires approval.
*
* Radarr hosts a public proxy that's in use by all Radarr instances.
*/
class IMDBRadarrProxy extends ExternalAPI {
constructor() {
super('https://api.radarr.video/v1', {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('imdb').data,
});
}
/**
* Ask the Radarr IMDB Proxy for the movie
*
* @param IMDBid Id of IMDB movie
*/
public async getMovieRatings(IMDBid: string): Promise<IMDBRating | null> {
try {
const data = await this.get<IMDBRadarrProxyResponse>(
`/movie/imdb/${IMDBid}`
);
if (!data?.length || data[0].ImdbId !== IMDBid) {
return null;
}
return {
title: data[0].Title,
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
criticsScore: data[0].MovieRatings.Imdb.Value,
};
} catch (e) {
throw new Error(
`[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}`
);
}
}
}
export default IMDBRadarrProxy;

View File

@@ -1,6 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import ExternalAPI from './externalapi';
interface RTAlgoliaSearchResponse {
results: {
@@ -17,7 +17,7 @@ interface RTAlgoliaHit {
title: string;
titles: string[];
description: string;
releaseYear: string;
releaseYear: number;
rating: string;
genres: string[];
updateDate: string;
@@ -111,22 +111,19 @@ class RottenTomatoes extends ExternalAPI {
// First, attempt to match exact name and year
let movie = contentResults.hits.find(
(movie) => movie.releaseYear === year.toString() && movie.title === name
(movie) => movie.releaseYear === year && movie.title === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = contentResults.hits.find(
(movie) =>
movie.releaseYear === year.toString() && movie.title.includes(name)
(movie) => movie.releaseYear === year && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = contentResults.hits.find(
(movie) => movie.releaseYear === year.toString()
);
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
}
// One last try, try exact name match only
@@ -147,6 +144,9 @@ class RottenTomatoes extends ExternalAPI {
? 'Fresh'
: 'Rotten',
criticsScore: movie.rottenTomatoes.criticsScore,
audienceRating:
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
audienceScore: movie.rottenTomatoes.audienceScore,
year: Number(movie.releaseYear),
};
} catch (e) {
@@ -181,7 +181,7 @@ class RottenTomatoes extends ExternalAPI {
if (year) {
tvshow = contentResults.hits.find(
(series) => series.releaseYear === year.toString()
(series) => series.releaseYear === year
);
}
@@ -195,6 +195,9 @@ class RottenTomatoes extends ExternalAPI {
criticsRating:
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
criticsScore: tvshow.rottenTomatoes.criticsScore,
audienceRating:
tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
audienceScore: tvshow.rottenTomatoes.audienceScore,
year: Number(tvshow.releaseYear),
};
} catch (e) {

7
server/api/ratings.ts Normal file
View File

@@ -0,0 +1,7 @@
import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy';
import { type RTRating } from '@server/api/rating/rottentomatoes';
export interface RatingResponse {
rt?: RTRating;
imdb?: IMDBRating;
}

View File

@@ -1,7 +1,7 @@
import logger from '@server/logger';
import ServarrBase from './base';
interface SonarrSeason {
export interface SonarrSeason {
seasonNumber: number;
monitored: boolean;
statistics?: {
@@ -76,6 +76,15 @@ export interface SonarrSeries {
ignoreEpisodesWithoutFiles?: boolean;
searchForMissingEpisodes?: boolean;
};
statistics: {
seasonCount: number;
episodeFileCount: number;
episodeCount: number;
totalEpisodeCount: number;
sizeOnDisk: number;
releaseGroups: string[];
percentOfEpisodes: number;
};
}
export interface AddSeriesOptions {
@@ -116,6 +125,16 @@ class SonarrAPI extends ServarrBase<{
}
}
public async getSeriesById(id: number): Promise<SonarrSeries> {
try {
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
return response.data;
} catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
}
}
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {

View File

@@ -65,6 +65,8 @@ interface DiscoverMovieOptions {
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
voteCountGte?: string;
voteCountLte?: string;
originalLanguage?: string;
genre?: string;
studio?: string;
@@ -83,6 +85,8 @@ interface DiscoverTvOptions {
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
voteCountGte?: string;
voteCountLte?: string;
includeEmptyReleaseDate?: boolean;
originalLanguage?: string;
genre?: string;
@@ -460,6 +464,8 @@ class TheMovieDb extends ExternalAPI {
withRuntimeLte,
voteAverageGte,
voteAverageLte,
voteCountGte,
voteCountLte,
watchProviders,
watchRegion,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
@@ -504,6 +510,8 @@ class TheMovieDb extends ExternalAPI {
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
watch_region: watchRegion,
with_watch_providers: watchProviders,
},
@@ -530,6 +538,8 @@ class TheMovieDb extends ExternalAPI {
withRuntimeLte,
voteAverageGte,
voteAverageLte,
voteCountGte,
voteCountLte,
watchProviders,
watchRegion,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
@@ -574,6 +584,8 @@ class TheMovieDb extends ExternalAPI {
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
with_watch_providers: watchProviders,
watch_region: watchRegion,
},

View File

@@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult {
first_air_date: string;
}
export interface TmdbCollectionResult {
id: number;
media_type: 'collection';
title: string;
original_title: string;
adult: boolean;
poster_path?: string;
backdrop_path?: string;
overview: string;
original_language: string;
}
export interface TmdbPersonResult {
id: number;
name: string;
@@ -45,7 +57,12 @@ interface TmdbPaginatedResponse {
}
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[];
}
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {

View File

@@ -20,6 +20,8 @@ export enum DiscoverSliderType {
TMDB_SEARCH,
TMDB_STUDIO,
TMDB_NETWORK,
TMDB_MOVIE_STREAMING_SERVICES,
TMDB_TV_STREAMING_SERVICES,
}
export const defaultSliders: Partial<DiscoverSlider>[] = [

View File

@@ -114,29 +114,29 @@ class Media {
@Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date;
@Column({ nullable: true })
public serviceId?: number;
@Column({ nullable: true, type: 'int' })
public serviceId?: number | null;
@Column({ nullable: true })
public serviceId4k?: number;
@Column({ nullable: true, type: 'int' })
public serviceId4k?: number | null;
@Column({ nullable: true })
public externalServiceId?: number;
@Column({ nullable: true, type: 'int' })
public externalServiceId?: number | null;
@Column({ nullable: true })
public externalServiceId4k?: number;
@Column({ nullable: true, type: 'int' })
public externalServiceId4k?: number | null;
@Column({ nullable: true })
public externalServiceSlug?: string;
@Column({ nullable: true, type: 'varchar' })
public externalServiceSlug?: string | null;
@Column({ nullable: true })
public externalServiceSlug4k?: string;
@Column({ nullable: true, type: 'varchar' })
public externalServiceSlug4k?: string | null;
@Column({ nullable: true })
public ratingKey?: string;
@Column({ nullable: true, type: 'varchar' })
public ratingKey?: string | null;
@Column({ nullable: true })
public ratingKey4k?: string;
@Column({ nullable: true, type: 'varchar' })
public ratingKey4k?: string | null;
public serviceUrl?: string;
public serviceUrl4k?: string;
@@ -260,7 +260,9 @@ class Media {
if (this.mediaType === MediaType.MOVIE) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getMovieProgress(
this.serviceId,
@@ -270,7 +272,9 @@ class Media {
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
) {
this.downloadStatus4k = downloadTracker.getMovieProgress(
this.serviceId4k,
@@ -282,7 +286,9 @@ class Media {
if (this.mediaType === MediaType.TV) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getSeriesProgress(
this.serviceId,
@@ -292,7 +298,9 @@ class Media {
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
) {
this.downloadStatus4k = downloadTracker.getSeriesProgress(
this.serviceId4k,

View File

@@ -704,7 +704,7 @@ export class MediaRequest {
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags;
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
if (
this.rootFolder &&
@@ -764,6 +764,38 @@ export class MediaRequest {
return;
}
if (radarrSettings.tagRequests) {
let userTag = (await radarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
);
if (!userTag) {
logger.info(`Requester has no active tag. Creating new`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
newTag:
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
userTag = await radarr.createTag({
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
}
if (userTag.id) {
if (!tags?.find((v) => v === userTag?.id)) {
tags?.push(userTag.id);
}
} else {
logger.warn(`Requester has no tag and failed to add one`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
});
}
}
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
@@ -952,7 +984,7 @@ export class MediaRequest {
(keyword) => keyword.id === ANIME_KEYWORD_ID
)
) {
seriesType = 'anime';
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
}
let rootFolder =
@@ -970,7 +1002,11 @@ export class MediaRequest {
let tags =
seriesType === 'anime'
? sonarrSettings.animeTags
: sonarrSettings.tags;
? [...sonarrSettings.animeTags]
: []
: sonarrSettings.tags
? [...sonarrSettings.tags]
: [];
if (
this.rootFolder &&
@@ -1022,6 +1058,38 @@ export class MediaRequest {
});
}
if (sonarrSettings.tagRequests) {
let userTag = (await sonarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
);
if (!userTag) {
logger.info(`Requester has no active tag. Creating new`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
newTag:
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
userTag = await sonarr.createTag({
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
}
if (userTag.id) {
if (!tags?.find((v) => v === userTag?.id)) {
tags?.push(userTag.id);
}
} else {
logger.warn(`Requester has no tag and failed to add one`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
});
}
}
const sonarrSeriesOptions: AddSeriesOptions = {
profileId: qualityProfile,
languageProfileId: languageProfile,
@@ -1187,3 +1255,5 @@ export class MediaRequest {
}
}
}
export default MediaRequest;

View File

@@ -1,5 +1,7 @@
import { MediaRequestStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import {
AfterRemove,
Column,
CreateDateColumn,
Entity,
@@ -34,6 +36,18 @@ class SeasonRequest {
constructor(init?: Partial<SeasonRequest>) {
Object.assign(this, init);
}
@AfterRemove()
public async handleRemoveParent(): Promise<void> {
const mediaRequestRepository = getRepository(MediaRequest);
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
where: { id: this.request.id },
});
if (requestToBeDeleted.seasons.length === 0) {
await mediaRequestRepository.delete({ id: this.request.id });
}
}
}
export default SeasonRequest;

View File

@@ -17,6 +17,7 @@ import WebhookAgent from '@server/lib/notifications/agents/webhook';
import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes';
import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion';
@@ -182,7 +183,8 @@ app
});
server.use('/api/v1', routes);
server.use('/imageproxy', imageproxy);
// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.get('*', (req, res) => handle(req, res));
server.use(

View File

@@ -26,6 +26,8 @@ export interface PublicSettingsResponse {
applicationUrl: string;
hideAvailable: boolean;
localLogin: boolean;
movieEnabled: boolean;
seriesEnabled: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
region: string;

View File

@@ -1,3 +1,4 @@
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
@@ -7,6 +8,7 @@ import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger';
import random from 'lodash/random';
import schedule from 'node-schedule';
interface ScheduledJob {
@@ -14,7 +16,7 @@ interface ScheduledJob {
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
running?: () => boolean;
cancelFn?: () => void;
@@ -30,7 +32,7 @@ export const startJobs = (): void => {
id: 'plex-recently-added-scan',
name: 'Plex Recently Added Scan',
type: 'process',
interval: 'short',
interval: 'minutes',
cronSchedule: jobs['plex-recently-added-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Recently Added Scan', {
@@ -47,7 +49,7 @@ export const startJobs = (): void => {
id: 'plex-full-scan',
name: 'Plex Full Library Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['plex-full-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Full Library Scan', {
@@ -59,27 +61,37 @@ export const startJobs = (): void => {
cancelFn: () => plexFullScanner.cancel(),
});
// Run watchlist sync every 5 minutes
scheduledJobs.push({
// Watchlist Sync
const watchlistSyncJob: ScheduledJob = {
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'short',
interval: 'fixed',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
};
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
// after each run
watchlistSyncJob.job.on('run', () => {
watchlistSyncJob.job.schedule(
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
);
});
scheduledJobs.push(watchlistSyncJob);
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',
name: 'Radarr Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['radarr-scan'].schedule,
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
@@ -94,7 +106,7 @@ export const startJobs = (): void => {
id: 'sonarr-scan',
name: 'Sonarr Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['sonarr-scan'].schedule,
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
@@ -104,12 +116,29 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(),
});
// Checks if media is still available in plex/sonarr/radarr libs
scheduledJobs.push({
id: 'availability-sync',
name: 'Media Availability Sync',
type: 'process',
interval: 'hours',
cronSchedule: jobs['availability-sync'].schedule,
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
logger.info('Starting scheduled job: Media Availability Sync', {
label: 'Jobs',
});
availabilitySync.run();
}),
running: () => availabilitySync.running,
cancelFn: () => availabilitySync.cancel(),
});
// Run download sync every minute
scheduledJobs.push({
id: 'download-sync',
name: 'Download Sync',
type: 'command',
interval: 'fixed',
interval: 'seconds',
cronSchedule: jobs['download-sync'].schedule,
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
logger.debug('Starting scheduled job: Download Sync', {
@@ -124,7 +153,7 @@ export const startJobs = (): void => {
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['download-sync-reset'].schedule,
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
logger.info('Starting scheduled job: Download Sync Reset', {
@@ -134,12 +163,12 @@ export const startJobs = (): void => {
}),
});
// Run image cache cleanup every 5 minutes
// Run image cache cleanup every 24 hours
scheduledJobs.push({
id: 'image-cache-cleanup',
name: 'Image Cache Cleanup',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['image-cache-cleanup'].schedule,
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
logger.info('Starting scheduled job: Image Cache Cleanup', {

View File

@@ -0,0 +1,725 @@
import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import MediaRequest from '@server/entity/MediaRequest';
import type Season from '@server/entity/Season';
import SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
class AvailabilitySync {
public running = false;
private plexClient: PlexAPI;
private plexSeasonsCache: Record<string, PlexMetadata[]>;
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[];
async run() {
const settings = getSettings();
this.running = true;
this.plexSeasonsCache = {};
this.sonarrSeasonsCache = {};
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
try {
logger.info(`Starting availability sync...`, {
label: 'AvailabilitySync',
});
const pageSize = 50;
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (admin) {
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
} else {
logger.error('An admin is not configured.');
}
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
if (!this.running) {
throw new Error('Job aborted');
}
// Check plex, radarr, and sonarr for that specific media and
// if unavailable, then we change the status accordingly.
// If a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
if (media.mediaType === 'movie') {
let movieExists = false;
let movieExists4k = false;
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex(
media,
true
);
const existsInRadarr = await this.mediaExistsInRadarr(media, false);
const existsInRadarr4k = await this.mediaExistsInRadarr(media, true);
if (existsInPlex || existsInRadarr) {
movieExists = true;
logger.info(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
if (existsInPlex4k || existsInRadarr4k) {
movieExists4k = true;
logger.info(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
if (!movieExists && media.status === MediaStatus.AVAILABLE) {
await this.mediaUpdater(media, false);
}
if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) {
await this.mediaUpdater(media, true);
}
}
// If both versions still exist in plex, we still need
// to check through sonarr to verify season availability
if (media.mediaType === 'tv') {
let showExists = false;
let showExists4k = false;
const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } =
await this.mediaExistsInPlex(media, false);
const {
existsInPlex: existsInPlex4k,
seasonsMap: plexSeasonsMap4k = new Map(),
} = await this.mediaExistsInPlex(media, true);
const { existsInSonarr, seasonsMap: sonarrSeasonsMap } =
await this.mediaExistsInSonarr(media, false);
const {
existsInSonarr: existsInSonarr4k,
seasonsMap: sonarrSeasonsMap4k,
} = await this.mediaExistsInSonarr(media, true);
if (existsInPlex || existsInSonarr) {
showExists = true;
logger.info(
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
if (existsInPlex4k || existsInSonarr4k) {
showExists4k = true;
logger.info(
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
// Here we will create a final map that will cross compare
// with plex and sonarr. Filtered seasons will go through
// each season and assume the season does not exist. If Plex or
// Sonarr finds that season, we will change the final seasons value
// to true.
const filteredSeasonsMap: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
season.status === MediaStatus.AVAILABLE ||
season.status === MediaStatus.PARTIALLY_AVAILABLE
)
.forEach((season) =>
filteredSeasonsMap.set(season.seasonNumber, false)
);
const finalSeasons = new Map([
...filteredSeasonsMap,
...plexSeasonsMap,
...sonarrSeasonsMap,
]);
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
season.status4k === MediaStatus.AVAILABLE ||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE
)
.forEach((season) =>
filteredSeasonsMap4k.set(season.seasonNumber, false)
);
const finalSeasons4k = new Map([
...filteredSeasonsMap4k,
...plexSeasonsMap4k,
...sonarrSeasonsMap4k,
]);
if ([...finalSeasons.values()].includes(false)) {
await this.seasonUpdater(media, finalSeasons, false);
}
if ([...finalSeasons4k.values()].includes(false)) {
await this.seasonUpdater(media, finalSeasons4k, true);
}
if (
!showExists &&
(media.status === MediaStatus.AVAILABLE ||
media.status === MediaStatus.PARTIALLY_AVAILABLE)
) {
await this.mediaUpdater(media, false);
}
if (
!showExists4k &&
(media.status4k === MediaStatus.AVAILABLE ||
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
) {
await this.mediaUpdater(media, true);
}
}
}
} catch (ex) {
logger.error('Failed to complete availability sync.', {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
} finally {
logger.info(`Availability sync complete.`, {
label: 'AvailabilitySync',
});
this.running = false;
}
}
public cancel() {
this.running = false;
}
private async *loadAvailableMediaPaginated(pageSize: number) {
let offset = 0;
const mediaRepository = getRepository(Media);
const whereOptions = [
{ status: MediaStatus.AVAILABLE },
{ status: MediaStatus.PARTIALLY_AVAILABLE },
{ status4k: MediaStatus.AVAILABLE },
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
];
let mediaPage: Media[];
do {
yield* (mediaPage = await mediaRepository.find({
where: whereOptions,
skip: offset,
take: pageSize,
}));
offset += pageSize;
} while (mediaPage.length > 0);
}
private findMediaStatus(
requests: MediaRequest[],
is4k: boolean
): MediaStatus {
const filteredRequests = requests.filter(
(request) => request.is4k === is4k
);
let mediaStatus: MediaStatus;
if (
filteredRequests.some(
(request) => request.status === MediaRequestStatus.APPROVED
)
) {
mediaStatus = MediaStatus.PROCESSING;
} else if (
filteredRequests.some(
(request) => request.status === MediaRequestStatus.PENDING
)
) {
mediaStatus = MediaStatus.PENDING;
} else {
mediaStatus = MediaStatus.UNKNOWN;
}
return mediaStatus;
}
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
try {
// Find all related requests only if
// the related media has an available status
const requests = await requestRepository
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.where('(media.id = :id)', {
id: media.id,
})
.andWhere(
`(request.is4k = :is4k AND media.${
is4k ? 'status4k' : 'status'
} IN (:...mediaStatus))`,
{
mediaStatus: [
MediaStatus.AVAILABLE,
MediaStatus.PARTIALLY_AVAILABLE,
],
is4k: is4k,
}
)
.getMany();
// Check if a season is processing or pending to
// make sure we set the media to the correct status
let mediaStatus = MediaStatus.UNKNOWN;
if (media.mediaType === 'tv') {
mediaStatus = this.findMediaStatus(requests, is4k);
}
media[is4k ? 'status4k' : 'status'] = mediaStatus;
media[is4k ? 'serviceId4k' : 'serviceId'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'serviceId4k' : 'serviceId']
: null;
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
: null;
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
: null;
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'ratingKey4k' : 'ratingKey']
: null;
logger.info(
`The ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'movie' ? 'movie' : 'show'
} [TMDB ID ${media.tmdbId}] was not found in any ${
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
} and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.save({ media, ...media });
// Only delete media request if type is movie.
// Type tv request deletion is handled
// in the season request entity
if (requests.length > 0 && media.mediaType === 'movie') {
await requestRepository.remove(requests);
}
} catch (ex) {
logger.debug(
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${media.tmdbId}].`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
private async seasonUpdater(
media: Media,
seasons: Map<number, boolean>,
is4k: boolean
): Promise<void> {
const mediaRepository = getRepository(Media);
const seasonRequestRepository = getRepository(SeasonRequest);
const seasonsPendingRemoval = new Map(
// Disabled linter as only the value is needed from the filter
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[...seasons].filter(([_, exists]) => !exists)
);
const seasonKeys = [...seasonsPendingRemoval.keys()];
try {
// Need to check and see if there are any related season
// requests. If they are, we will need to delete them.
const seasonRequests = await seasonRequestRepository
.createQueryBuilder('seasonRequest')
.leftJoinAndSelect('seasonRequest.request', 'request')
.leftJoinAndSelect('request.media', 'media')
.where('(media.id = :id)', { id: media.id })
.andWhere(
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
{
seasonNumbers: seasonKeys,
is4k: is4k,
}
)
.getMany();
for (const mediaSeason of media.seasons) {
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
}
}
if (media.status === MediaStatus.AVAILABLE) {
media.status = MediaStatus.PARTIALLY_AVAILABLE;
logger.info(
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
);
}
if (media.status4k === MediaStatus.AVAILABLE) {
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
logger.info(
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
);
}
await mediaRepository.save({ media, ...media });
if (seasonRequests.length > 0) {
await seasonRequestRepository.remove(seasonRequests);
}
logger.info(
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
media.tmdbId
}] was not found in any ${
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
} and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
);
} catch (ex) {
logger.debug(
`Failure updating the ${
is4k ? '4K' : 'non-4K'
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
private async mediaExistsInRadarr(
media: Media,
is4k: boolean
): Promise<boolean> {
let existsInRadarr = false;
// Check for availability in all of the available radarr servers
// If any find the media, we will assume the media exists
for (const server of this.radarrServers) {
const radarrAPI = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
try {
let radarr: RadarrMovie | undefined;
if (!server.is4k && media.externalServiceId && !is4k) {
radarr = await radarrAPI.getMovie({
id: media.externalServiceId,
});
}
if (server.is4k && media.externalServiceId4k && is4k) {
radarr = await radarrAPI.getMovie({
id: media.externalServiceId4k,
});
}
if (radarr && radarr.hasFile) {
existsInRadarr = true;
}
} catch (ex) {
if (!ex.message.includes('404')) {
existsInRadarr = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
media.tmdbId
}] from Radarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
}
return existsInRadarr;
}
private async mediaExistsInSonarr(
media: Media,
is4k: boolean
): Promise<{ existsInSonarr: boolean; seasonsMap: Map<number, boolean> }> {
let existsInSonarr = false;
let preventSeasonSearch = false;
// Check for availability in all of the available sonarr servers
// If any find the media, we will assume the media exists
for (const server of this.sonarrServers) {
const sonarrAPI = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
try {
let sonarr: SonarrSeries | undefined;
if (!server.is4k && media.externalServiceId && !is4k) {
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
sonarr.seasons;
}
if (server.is4k && media.externalServiceId4k && is4k) {
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
sonarr.seasons;
}
if (sonarr && sonarr.statistics.episodeFileCount > 0) {
existsInSonarr = true;
}
} catch (ex) {
if (!ex.message.includes('404')) {
existsInSonarr = true;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
media.tmdbId
}] from Sonarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
}
// Here we check each season for availability
// If the API returns an error other than a 404,
// we will have to prevent the season check from happening
const seasonsMap: Map<number, boolean> = new Map();
if (!preventSeasonSearch) {
const filteredSeasons = media.seasons.filter(
(season) =>
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
season[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE
);
for (const season of filteredSeasons) {
const seasonExists = await this.seasonExistsInSonarr(
media,
season,
is4k
);
if (seasonExists) {
seasonsMap.set(season.seasonNumber, true);
}
}
}
return { existsInSonarr, seasonsMap };
}
private async seasonExistsInSonarr(
media: Media,
season: Season,
is4k: boolean
): Promise<boolean> {
let seasonExists = false;
// Check each sonarr instance to see if the media still exists
// If found, we will assume the media exists and prevent removal
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
for (const server of this.sonarrServers) {
let sonarrSeasons: SonarrSeason[] | undefined;
if (media.externalServiceId && !is4k) {
sonarrSeasons =
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`];
}
if (media.externalServiceId4k && is4k) {
sonarrSeasons =
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`];
}
const seasonIsAvailable = sonarrSeasons?.find(
({ seasonNumber, statistics }) =>
season.seasonNumber === seasonNumber &&
statistics?.episodeFileCount &&
statistics?.episodeFileCount > 0
);
if (seasonIsAvailable && sonarrSeasons) {
seasonExists = true;
}
}
return seasonExists;
}
private async mediaExistsInPlex(
media: Media,
is4k: boolean
): Promise<{ existsInPlex: boolean; seasonsMap?: Map<number, boolean> }> {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let existsInPlex = false;
let preventSeasonSearch = false;
// Check each plex instance to see if the media still exists
// If found, we will assume the media exists and prevent removal
// We can use the cache we built when we fetched the series with mediaExistsInPlex
try {
let plexMedia: PlexMetadata | undefined;
if (ratingKey && !is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey);
if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey] =
await this.plexClient?.getChildrenMetadata(ratingKey);
}
}
if (ratingKey4k && is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k);
}
}
if (plexMedia) {
existsInPlex = true;
}
} catch (ex) {
if (!ex.message.includes('404')) {
existsInPlex = true;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${media.tmdbId}] from Plex.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
// Here we check each season in plex for availability
// If the API returns an error other than a 404,
// we will have to prevent the season check from happening
if (media.mediaType === 'tv') {
const seasonsMap: Map<number, boolean> = new Map();
if (!preventSeasonSearch) {
const filteredSeasons = media.seasons.filter(
(season) =>
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
season[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE
);
for (const season of filteredSeasons) {
const seasonExists = await this.seasonExistsInPlex(
media,
season,
is4k
);
if (seasonExists) {
seasonsMap.set(season.seasonNumber, true);
}
}
}
return { existsInPlex, seasonsMap };
}
return { existsInPlex };
}
private async seasonExistsInPlex(
media: Media,
season: Season,
is4k: boolean
): Promise<boolean> {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let seasonExistsInPlex = false;
// Check each plex instance to see if the season exists
let plexSeasons: PlexMetadata[] | undefined;
if (ratingKey && !is4k) {
plexSeasons = this.plexSeasonsCache[ratingKey];
}
if (ratingKey4k && is4k) {
plexSeasons = this.plexSeasonsCache[ratingKey4k];
}
const seasonIsAvailable = plexSeasons?.find(
(plexSeason) => plexSeason.index === season.seasonNumber
);
if (seasonIsAvailable) {
seasonExistsInPlex = true;
}
return seasonExistsInPlex;
}
}
const availabilitySync = new AvailabilitySync();
export default availabilitySync;

View File

@@ -5,6 +5,7 @@ export type AvailableCacheIds =
| 'radarr'
| 'sonarr'
| 'rt'
| 'imdb'
| 'github'
| 'plexguid'
| 'plextv';
@@ -51,6 +52,10 @@ class CacheManager {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
imdb: new Cache('imdb', 'IMDB Radarr Proxy', {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
github: new Cache('github', 'GitHub API', {
stdTtl: 21600,
checkPeriod: 60 * 30,

View File

@@ -18,14 +18,14 @@ type ImageResponse = {
imageBuffer: Buffer;
};
const baseCacheDirectory = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/cache/images`
: path.join(__dirname, '../../config/cache/images');
class ImageProxy {
public static async clearCache(key: string) {
let deletedImages = 0;
const cacheDirectory = path.join(
__dirname,
'../../config/cache/images/',
key
);
const cacheDirectory = path.join(baseCacheDirectory, key);
const files = await promises.readdir(cacheDirectory);
@@ -57,11 +57,7 @@ class ImageProxy {
public static async getImageStats(
key: string
): Promise<{ size: number; imageCount: number }> {
const cacheDirectory = path.join(
__dirname,
'../../config/cache/images/',
key
);
const cacheDirectory = path.join(baseCacheDirectory, key);
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
@@ -263,7 +259,7 @@ class ImageProxy {
}
private getCacheDirectory() {
return path.join(__dirname, '../../config/cache/images/', this.key);
return path.join(baseCacheDirectory, this.key);
}
}

View File

@@ -61,6 +61,7 @@ export interface DVRSettings {
externalUrl?: string;
syncEnabled: boolean;
preventSearch: boolean;
tagRequests: boolean;
}
export interface RadarrSettings extends DVRSettings {
@@ -68,6 +69,8 @@ export interface RadarrSettings extends DVRSettings {
}
export interface SonarrSettings extends DVRSettings {
seriesType: 'standard' | 'daily' | 'anime';
animeSeriesType: 'standard' | 'daily' | 'anime';
activeAnimeProfileId?: number;
activeAnimeProfileName?: string;
activeAnimeDirectory?: string;
@@ -112,6 +115,8 @@ interface FullPublicSettings extends PublicSettings {
applicationUrl: string;
hideAvailable: boolean;
localLogin: boolean;
movieEnabled: boolean;
seriesEnabled: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
region: string;
@@ -248,7 +253,8 @@ export type JobId =
| 'sonarr-scan'
| 'download-sync'
| 'download-sync-reset'
| 'image-cache-cleanup';
| 'image-cache-cleanup'
| 'availability-sync';
interface AllSettings {
clientId: string;
@@ -376,7 +382,7 @@ class Settings {
options: {
webhookUrl: '',
jsonPayload:
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
},
},
webpush: {
@@ -409,6 +415,9 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
'availability-sync': {
schedule: '0 0 5 * * *',
},
'download-sync': {
schedule: '0 * * * * *',
},
@@ -484,6 +493,12 @@ class Settings {
applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin,
movieEnabled: this.data.radarr.some(
(radarr) => !radarr.is4k && radarr.isDefault
),
seriesEnabled: this.data.sonarr.some(
(sonarr) => !sonarr.is4k && sonarr.isDefault
),
movie4kEnabled: this.data.radarr.some(
(radarr) => radarr.is4k && radarr.isDefault
),
@@ -546,7 +561,7 @@ class Settings {
}
private generateApiKey(): string {
return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64');
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
}
private generateVapidKeys(force = false): void {

View File

@@ -0,0 +1,6 @@
const clearCookies: Middleware = (_req, res, next) => {
res.removeHeader('Set-Cookie');
next();
};
export default clearCookies;

View File

@@ -1,4 +1,5 @@
import type {
TmdbCollectionResult,
TmdbMovieDetails,
TmdbMovieResult,
TmdbPersonDetails,
@@ -9,7 +10,7 @@ import type {
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
export type MediaType = 'tv' | 'movie' | 'person';
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
interface SearchResult {
id: number;
@@ -43,6 +44,18 @@ export interface TvResult extends SearchResult {
firstAirDate: string;
}
export interface CollectionResult {
id: number;
mediaType: 'collection';
title: string;
originalTitle: string;
adult: boolean;
posterPath?: string;
backdropPath?: string;
overview: string;
originalLanguage: string;
}
export interface PersonResult {
id: number;
name: string;
@@ -53,7 +66,7 @@ export interface PersonResult {
knownFor: (MovieResult | TvResult)[];
}
export type Results = MovieResult | TvResult | PersonResult;
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
export const mapMovieResult = (
movieResult: TmdbMovieResult,
@@ -99,6 +112,20 @@ export const mapTvResult = (
mediaInfo: media,
});
export const mapCollectionResult = (
collectionResult: TmdbCollectionResult
): CollectionResult => ({
id: collectionResult.id,
mediaType: collectionResult.media_type || 'collection',
adult: collectionResult.adult,
originalLanguage: collectionResult.original_language,
originalTitle: collectionResult.original_title,
title: collectionResult.title,
overview: collectionResult.overview,
backdropPath: collectionResult.backdrop_path,
posterPath: collectionResult.poster_path,
});
export const mapPersonResult = (
personResult: TmdbPersonResult
): PersonResult => ({
@@ -118,7 +145,12 @@ export const mapPersonResult = (
});
export const mapSearchResults = (
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[],
results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[],
media?: Media[]
): Results[] =>
results.map((result) => {
@@ -139,6 +171,8 @@ export const mapSearchResults = (
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
)
);
case 'collection':
return mapCollectionResult(result);
default:
return mapPersonResult(result);
}

View File

@@ -14,12 +14,13 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie';
import {
mapCollectionResult,
mapMovieResult,
mapPersonResult,
mapTvResult,
} from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv';
import { isMovie, isPerson } from '@server/utils/typeHelpers';
import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import { sortBy } from 'lodash';
import { z } from 'zod';
@@ -64,6 +65,8 @@ const QueryFilterOptions = z.object({
withRuntimeLte: z.coerce.string().optional(),
voteAverageGte: z.coerce.string().optional(),
voteAverageLte: z.coerce.string().optional(),
voteCountGte: z.coerce.string().optional(),
voteCountLte: z.coerce.string().optional(),
network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(),
@@ -95,6 +98,8 @@ discoverRoutes.get('/movies', async (req, res, next) => {
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
@@ -370,6 +375,8 @@ discoverRoutes.get('/tv', async (req, res, next) => {
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
@@ -647,6 +654,8 @@ discoverRoutes.get('/trending', async (req, res, next) => {
)
: isPerson(result)
? mapPersonResult(result)
: isCollection(result)
? mapCollectionResult(result)
: mapTvResult(
result,
media.find(
@@ -800,12 +809,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
'/watchlist',
async (req, res) => {
const userRepository = getRepository(User);
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({
@@ -829,8 +838,8 @@ discoverRoutes.get<{ page?: number }, WatchlistResponse>(
return res.json({
page,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,

View File

@@ -1,4 +1,6 @@
import RottenTomatoes from '@server/api/rottentomatoes';
import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import { type RatingResponse } from '@server/api/ratings';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import Media from '@server/entity/Media';
@@ -116,6 +118,9 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
}
});
/**
* Endpoint backed by RottenTomatoes
*/
movieRoutes.get('/:id/ratings', async (req, res, next) => {
const tmdb = new TheMovieDb();
const rtapi = new RottenTomatoes();
@@ -151,4 +156,53 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => {
}
});
/**
* Endpoint combining RottenTomatoes and IMDB
*/
movieRoutes.get('/:id/ratingscombined', async (req, res, next) => {
const tmdb = new TheMovieDb();
const rtapi = new RottenTomatoes();
const imdbApi = new IMDBRadarrProxy();
try {
const movie = await tmdb.getMovie({
movieId: Number(req.params.id),
});
const rtratings = await rtapi.getMovieRatings(
movie.title,
Number(movie.release_date.slice(0, 4))
);
let imdbRatings;
if (movie.imdb_id) {
imdbRatings = await imdbApi.getMovieRatings(movie.imdb_id);
}
if (!rtratings && !imdbRatings) {
return next({
status: 404,
message: 'No ratings found.',
});
}
const ratings: RatingResponse = {
...(rtratings ? { rt: rtratings } : {}),
...(imdbRatings ? { imdb: imdbRatings } : {}),
};
return res.status(200).json(ratings);
} catch (e) {
logger.debug('Something went wrong retrieving movie ratings', {
label: 'API',
errorMessage: e.message,
movieId: req.params.id,
});
return next({
status: 500,
message: 'Unable to retrieve movie ratings.',
});
}
});
export default movieRoutes;

View File

@@ -183,9 +183,7 @@ serviceRoutes.get<{ tmdbId: string }>(
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
try {

View File

@@ -254,25 +254,27 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
Object.assign(settings.tautulli, req.body);
try {
const tautulliClient = new TautulliAPI(settings.tautulli);
if (settings.tautulli.hostname) {
try {
const tautulliClient = new TautulliAPI(settings.tautulli);
const result = await tautulliClient.getInfo();
const result = await tautulliClient.getInfo();
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
throw new Error('Tautulli version not supported');
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
throw new Error('Tautulli version not supported');
}
settings.save();
} catch (e) {
logger.error('Something went wrong testing Tautulli connection', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to connect to Tautulli.',
});
}
settings.save();
} catch (e) {
logger.error('Something went wrong testing Tautulli connection', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to connect to Tautulli.',
});
}
return res.status(200).json(settings.tautulli);

View File

@@ -1,4 +1,4 @@
import RottenTomatoes from '@server/api/rottentomatoes';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import Media from '@server/entity/Media';

View File

@@ -381,7 +381,14 @@ router.delete<{ id: string }>(
* we manually remove all requests from the user here so the parent media's
* properly reflect the change.
*/
await requestRepository.remove(user.requests);
await requestRepository.remove(user.requests, {
/**
* Break-up into groups of 1000 requests to be removed at a time.
* Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs.
* https://typeorm.io/repository-api#additional-options
*/
chunk: user.requests.length / 1000,
});
await userRepository.delete(user.id);
return res.status(200).json(user.filter());
@@ -607,7 +614,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
}
);
router.get<{ id: string; page?: number }, WatchlistResponse>(
router.get<{ id: string }, WatchlistResponse>(
'/:id/watchlist',
async (req, res, next) => {
if (
@@ -627,7 +634,7 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
}
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage;
const user = await getRepository(User).findOneOrFail({
@@ -651,8 +658,8 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
return res.json({
page,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,

View File

@@ -1,4 +1,5 @@
import type {
TmdbCollectionResult,
TmdbMovieDetails,
TmdbMovieResult,
TmdbPersonDetails,
@@ -8,17 +9,35 @@ import type {
} from '@server/api/themoviedb/interfaces';
export const isMovie = (
movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
movie:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
): movie is TmdbMovieResult => {
return (movie as TmdbMovieResult).title !== undefined;
};
export const isPerson = (
person: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
person:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
): person is TmdbPersonResult => {
return (person as TmdbPersonResult).known_for !== undefined;
};
export const isCollection = (
collection:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
): collection is TmdbCollectionResult => {
return (collection as TmdbCollectionResult).media_type === 'collection';
};
export const isMovieDetails = (
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
): movie is TmdbMovieDetails => {

View File

@@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import type { Collection } from '@server/models/Collection';
@@ -39,20 +40,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
const [requestModal, setRequestModal] = useState(false);
const [is4k, setIs4k] = useState(false);
const {
data,
error,
mutate: revalidate,
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
fallbackData: collection,
revalidateOnMount: true,
});
const { data: genres } =
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
const [downloadStatus, downloadStatus4k] = useMemo(() => {
return [
const returnCollectionDownloadItems = (data: Collection | undefined) => {
const [downloadStatus, downloadStatus4k] = [
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
),
@@ -60,7 +49,30 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
}, [data?.parts]);
return { downloadStatus, downloadStatus4k };
};
const {
data,
error,
mutate: revalidate,
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
fallbackData: collection,
revalidateOnMount: true,
refreshInterval: refreshIntervalHelper(
returnCollectionDownloadItems(collection),
15000
),
});
const { data: genres } =
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
const [downloadStatus, downloadStatus4k] = useMemo(() => {
const downloadItems = returnCollectionDownloadItems(data);
return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
}, [data]);
const [titles, titles4k] = useMemo(() => {
return [
@@ -117,6 +129,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
}
const hasRequestable =
settings.currentSettings.movieEnabled &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) &&
@@ -225,14 +238,19 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
</div>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={collectionStatus}
downloadItem={downloadStatus}
title={titles}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
{settings.currentSettings.movieEnabled &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) && (
<StatusBadge
status={collectionStatus}
downloadItem={downloadStatus}
title={titles}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
/>
)}
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
@@ -328,6 +346,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
@@ -336,7 +355,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
/>
))}
/>
<div className="pb-8" />
<div className="extra-bottom-space relative" />
</div>
);
};

View File

@@ -71,7 +71,7 @@ const Badge = (
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
);
if (href) {
badgeStyle.push('hover:bg-indigo-500 bg-opacity-100');
badgeStyle.push('hover:bg-indigo-500 hover:bg-opacity-100');
}
}

View File

@@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
<Transition
as={Fragment}
show={isOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
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

View File

@@ -5,6 +5,7 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type {
CollectionResult,
MovieResult,
PersonResult,
TvResult,
@@ -12,7 +13,7 @@ import type {
import { useIntl } from 'react-intl';
type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult)[];
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
plexItems?: WatchlistItem[];
isEmpty?: boolean;
isLoading?: boolean;
@@ -60,6 +61,7 @@ const ListView = ({
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
@@ -78,6 +80,7 @@ const ListView = ({
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}
@@ -90,6 +93,18 @@ const ListView = ({
/>
);
break;
case 'collection':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
summary={title.overview}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'person':
titleCard = (
<PersonCard

View File

@@ -78,10 +78,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
appear
as="div"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
ref={parentRef}
@@ -89,10 +89,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<Transition
appear
as={Fragment}
enter="transition opacity-0 duration-300 transform scale-75"
enter="transition duration-300"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={loading}
@@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
</div>
</Transition>
<Transition
className="hide-scrollbar relative inline-block w-full transform overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
@@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
}}
appear
as="div"
enter="transition opacity-0 duration-300 transform scale-75"
enter="transition duration-300"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={!loading}

View File

@@ -29,7 +29,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
aria-hidden="true"
className={`${
checked ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
);

View File

@@ -37,10 +37,10 @@ const SlideOver = ({
as={Fragment}
show={show}
appear
enter="opacity-0 transition ease-in-out duration-300"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition ease-in-out duration-300"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@@ -58,16 +58,16 @@ const SlideOver = ({
<section className="absolute inset-y-0 right-0 flex max-w-full">
<Transition.Child
appear
enter="transform transition ease-in-out duration-500 sm:duration-700"
enter="transition-transform ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leave="transition-transform ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
className="slideover relative h-full w-screen max-w-md p-2 sm:p-3"
ref={slideoverRef}
onClick={(e) => e.stopPropagation()}
>

View File

@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider';
import { WatchProviderSelector } from '@app/components/Selector';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import type {
TmdbCompanySearchResponse,
@@ -55,7 +56,7 @@ type CreateOption = {
dataUrl: string;
params?: string;
titlePlaceholderText: string;
dataPlaceholderText: string;
dataPlaceholderText?: string;
};
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
@@ -276,6 +277,20 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
},
{
type: DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES,
title: intl.formatMessage(sliderTitles.tmdbmoviestreamingservices),
dataUrl: '/api/v1/discover/movies',
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
},
{
type: DiscoverSliderType.TMDB_TV_STREAMING_SERVICES,
title: intl.formatMessage(sliderTitles.tmdbtvstreamingservices),
dataUrl: '/api/v1/discover/tv',
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
},
];
return (
@@ -417,6 +432,40 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
dataInput = (
<WatchProviderSelector
type={'movie'}
region={slider?.data?.split(',')[0]}
activeProviders={
slider?.data
?.split(',')[1]
.split('|')
.map((v) => Number(v)) ?? []
}
onChange={(region, providers) => {
setFieldValue('data', `${region},${providers.join('|')}`);
}}
/>
);
break;
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
dataInput = (
<WatchProviderSelector
type={'tv'}
region={slider?.data?.split(',')[0]}
activeProviders={
slider?.data
?.split(',')[1]
.split('|')
.map((v) => Number(v)) ?? []
}
onChange={(region, providers) => {
setFieldValue('data', `${region},${providers.join('|')}`);
}}
/>
);
break;
default:
dataInput = (
<Field
@@ -488,10 +537,25 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
'$value',
encodeURIExtraParams(values.data)
)}
extraParams={activeOption.params?.replace(
'$value',
encodeURIExtraParams(values.data)
)}
extraParams={
activeOption.type ===
DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES ||
activeOption.type ===
DiscoverSliderType.TMDB_TV_STREAMING_SERVICES
? activeOption.params
?.replace(
'$regionValue',
encodeURIExtraParams(values?.data.split(',')[0])
)
.replace(
'$providersValue',
encodeURIExtraParams(values?.data.split(',')[1])
)
: activeOption.params?.replace(
'$value',
encodeURIExtraParams(values.data)
)
}
onNewTitles={updateResultCount}
/>
</div>

View File

@@ -164,6 +164,10 @@ const DiscoverSliderEdit = ({
return intl.formatMessage(sliderTitles.tmdbnetwork);
case DiscoverSliderType.TMDB_SEARCH:
return intl.formatMessage(sliderTitles.tmdbsearch);
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices);
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
return intl.formatMessage(sliderTitles.tmdbtvstreamingservices);
default:
return 'Unknown Slider';
}
@@ -195,7 +199,9 @@ const DiscoverSliderEdit = ({
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
>
<Bars3Icon className="h-6 w-6" />
<div>{getSliderTitle(slider)}</div>
<div className="w-7/12 truncate md:w-full">
{getSliderTitle(slider)}
</div>
</div>
<div
className={`pointer-events-none ${

View File

@@ -35,8 +35,10 @@ const messages = defineMessages({
ratingText: 'Ratings between {minValue} and {maxValue}',
clearfilters: 'Clear Active Filters',
tmdbuserscore: 'TMDB User Score',
tmdbuservotecount: 'TMDB User Vote Count',
runtime: 'Runtime',
streamingservices: 'Streaming Services',
voteCount: 'Number of votes between {minValue} and {maxValue}',
});
type FilterSlideoverProps = {
@@ -246,6 +248,45 @@ const FilterSlideover = ({
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuservotecount)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={1000}
defaultMaxValue={
currentFilters.voteCountLte
? Number(currentFilters.voteCountLte)
: undefined
}
defaultMinValue={
currentFilters.voteCountGte
? Number(currentFilters.voteCountGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteCountGte',
min !== 0 && Number(currentFilters.voteCountLte) !== 1000
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteCountLte',
max !== 1000 && Number(currentFilters.voteCountGte) !== 0
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.voteCount, {
minValue: currentFilters.voteCountGte ?? 0,
maxValue: currentFilters.voteCountLte ?? 1000,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)}
</span>

View File

@@ -139,6 +139,12 @@ const networks: Network[] = [
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
url: '/discover/tv/network/13',
},
{
name: 'Peacock',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gIAcGTjKKr0KOHL5s4O36roJ8p7.png',
url: '/discover/tv/network/3353',
},
];
const NetworkSlider = () => {

View File

@@ -86,6 +86,8 @@ export const sliderTitles = defineMessages({
tmdbnetwork: 'TMDB Network',
tmdbstudio: 'TMDB Studio',
tmdbsearch: 'TMDB Search',
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
});
export const QueryFilterOptions = z.object({
@@ -102,6 +104,8 @@ export const QueryFilterOptions = z.object({
withRuntimeLte: z.string().optional(),
voteAverageGte: z.string().optional(),
voteAverageLte: z.string().optional(),
voteCountLte: z.string().optional(),
voteCountGte: z.string().optional(),
watchRegion: z.string().optional(),
watchProviders: z.string().optional(),
});
@@ -167,6 +171,14 @@ export const prepareFilterValues = (
filterValues.voteAverageLte = values.voteAverageLte;
}
if (values.voteCountGte) {
filterValues.voteCountGte = values.voteCountGte;
}
if (values.voteCountLte) {
filterValues.voteCountLte = values.voteCountLte;
}
if (values.watchProviders) {
filterValues.watchProviders = values.watchProviders;
}
@@ -188,6 +200,12 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
delete clonedFilters.voteAverageLte;
}
if (clonedFilters.voteCountGte || filterValues.voteCountLte) {
totalCount += 1;
delete clonedFilters.voteCountGte;
delete clonedFilters.voteCountLte;
}
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
totalCount += 1;
delete clonedFilters.withRuntimeGte;

View File

@@ -165,10 +165,10 @@ const Discover = () => {
</Transition>
<Transition
show={isEditing}
enter="transition transform duration-300"
enter="transition duration-300"
enterFrom="opacity-0 translate-y-6"
enterTo="opacity-100 translate-y-0"
leave="transition duration-300 transform"
leave="transition duration-300"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-6"
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
@@ -365,6 +365,36 @@ const Discover = () => {
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/movies"
extraParams={`watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
linkUrl={`/discover/movies?watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
/>
);
break;
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/tv"
extraParams={`watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
linkUrl={`/discover/tv?watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
/>
);
break;
}
if (isEditing) {

View File

@@ -65,10 +65,10 @@ const IssueComment = ({
>
<Transition
as={Fragment}
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showDeleteModal}
@@ -115,11 +115,11 @@ const IssueComment = ({
as={Fragment}
show={open}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items
static
@@ -164,7 +164,7 @@ const IssueComment = ({
</Menu>
)}
<div
className={`absolute top-3 z-10 h-3 w-3 rotate-45 transform bg-gray-800 shadow ring-1 ring-gray-500 ${
className={`absolute top-3 z-10 h-3 w-3 rotate-45 bg-gray-800 shadow ring-1 ring-gray-500 ${
isReversed ? '-left-1' : '-right-1'
}`}
/>

View File

@@ -57,11 +57,11 @@ const IssueDescription = ({
show={open}
as="div"
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items
static

View File

@@ -182,10 +182,10 @@ const IssueDetails = () => {
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
<Transition
as="div"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showDeleteModal}

View File

@@ -72,8 +72,12 @@ const CreateIssueModal = ({
const availableSeasons = (data?.mediaInfo?.seasons ?? [])
.filter(
(season) =>
season.status === MediaStatus.AVAILABLE ||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
(settings.currentSettings.seriesEnabled &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
}) &&
(season.status === MediaStatus.AVAILABLE ||
season.status === MediaStatus.PARTIALLY_AVAILABLE)) ||
(settings.currentSettings.series4kEnabled &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',

View File

@@ -12,10 +12,10 @@ interface IssueModalProps {
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
<Transition
as="div"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}

View File

@@ -34,12 +34,12 @@ const LanguagePicker = () => {
<Transition
as="div"
show={isDropdownOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
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 mt-2 w-56 origin-top-right rounded-md shadow-lg"

View File

@@ -131,13 +131,13 @@ const MobileMenu = () => {
show={isOpen}
as="div"
ref={ref}
enter="transition transform duration-500"
enter="transition duration-500"
enterFrom="opacity-0 translate-y-0"
enterTo="opacity-100 -translate-y-full"
leave="transition duration-500 transform"
leave="transition duration-500"
leaveFrom="opacity-100 -translate-y-full"
leaveTo="opacity-0 translate-y-0"
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
>
{filteredLinks.map((link) => {
const isActive = router.pathname.match(link.activeRegExp);

View File

@@ -0,0 +1,118 @@
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
const PullToRefresh = () => {
const router = useRouter();
const [pullStartPoint, setPullStartPoint] = useState(0);
const [pullChange, setPullChange] = useState(0);
const refreshDiv = useRef<HTMLDivElement>(null);
// Various pull down thresholds that determine icon location
const pullDownInitThreshold = pullChange > 20;
const pullDownStopThreshold = 120;
const pullDownReloadThreshold = pullChange > 340;
const pullDownIconLocation = pullChange / 3;
useEffect(() => {
// Reload function that is called when reload threshold has been hit
// Add loading class to determine when to add spin animation
const forceReload = () => {
refreshDiv.current?.classList.add('loading');
setTimeout(() => {
router.reload();
}, 1000);
};
const html = document.querySelector('html');
// Determines if we are at the top of the page
// Locks or unlocks page when pulling down to refresh
const pullStart = (e: TouchEvent) => {
setPullStartPoint(e.targetTouches[0].screenY);
if (window.scrollY === 0 && window.scrollX === 0) {
refreshDiv.current?.classList.add('block');
refreshDiv.current?.classList.remove('hidden');
document.body.style.touchAction = 'none';
document.body.style.overscrollBehavior = 'none';
if (html) {
html.style.overscrollBehaviorY = 'none';
}
} else {
refreshDiv.current?.classList.remove('block');
refreshDiv.current?.classList.add('hidden');
}
};
// Tracks how far we have pulled down the refresh icon
const pullDown = async (e: TouchEvent) => {
const screenY = e.targetTouches[0].screenY;
const pullLength =
pullStartPoint < screenY ? Math.abs(screenY - pullStartPoint) : 0;
setPullChange(pullLength);
};
// Will reload the page if we are past the threshold
// Otherwise, we reset the pull
const pullFinish = () => {
setPullStartPoint(0);
if (pullDownReloadThreshold) {
forceReload();
} else {
setPullChange(0);
}
document.body.style.touchAction = 'auto';
document.body.style.overscrollBehaviorY = 'auto';
if (html) {
html.style.overscrollBehaviorY = 'auto';
}
};
window.addEventListener('touchstart', pullStart, { passive: false });
window.addEventListener('touchmove', pullDown, { passive: false });
window.addEventListener('touchend', pullFinish, { passive: false });
return () => {
window.removeEventListener('touchstart', pullStart);
window.removeEventListener('touchmove', pullDown);
window.removeEventListener('touchend', pullFinish);
};
}, [pullDownInitThreshold, pullDownReloadThreshold, pullStartPoint, router]);
return (
<div
ref={refreshDiv}
className="absolute left-0 right-0 top-0 z-50 m-auto w-fit transition-all ease-out"
id="refreshIcon"
style={{
top:
pullDownIconLocation < pullDownStopThreshold && pullDownInitThreshold
? pullDownIconLocation
: pullDownInitThreshold
? pullDownStopThreshold
: '',
}}
>
<div
className={`${
refreshDiv.current?.classList.contains('loading') && 'animate-spin'
} relative -top-24 h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 shadow-md shadow-black ring-1 ring-gray-700`}
style={{ animationDirection: 'reverse' }}
>
<ArrowPathIcon
className={`rounded-full ${
pullDownReloadThreshold && 'rotate-180'
} text-indigo-500 transition-all duration-300`}
/>
</div>
</div>
);
};
export default PullToRefresh;

View File

@@ -71,9 +71,7 @@ const SidebarLinks: SidebarLinkProps[] = [
{
href: '/issues',
messagesKey: 'issues',
svgIcon: (
<ExclamationTriangleIcon className="mr-3 h-6 w-6 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
),
svgIcon: <ExclamationTriangleIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/issues/,
requiredPermission: [
Permission.MANAGE_ISSUES,
@@ -127,10 +125,10 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
</Transition.Child>
<Transition.Child
as="div"
enter="transition ease-in-out duration-300 transform"
enter="transition-transform ease-in-out duration-300"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave="transition-transform ease-in-out duration-300"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>

View File

@@ -63,11 +63,11 @@ const UserDropdown = () => {
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
appear
>
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">

View File

@@ -1,8 +1,8 @@
import MobileMenu from '@app/components/Layout/MobileMenu';
import PullToRefresh from '@app/components/Layout/PullToRefresh';
import SearchInput from '@app/components/Layout/SearchInput';
import Sidebar from '@app/components/Layout/Sidebar';
import UserDropdown from '@app/components/Layout/UserDropdown';
import PullToRefresh from '@app/components/PullToRefresh';
import type { AvailableLocale } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';

View File

@@ -95,10 +95,10 @@ const Login = () => {
<Transition
as="div"
show={!!error}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>

View File

@@ -1,6 +1,7 @@
import Button from '@app/components/Common/Button';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip';
import DownloadBlock from '@app/components/DownloadBlock';
import IssueBlock from '@app/components/IssueBlock';
import RequestBlock from '@app/components/RequestBlock';
@@ -144,20 +145,24 @@ const ManageSlideOver = ({
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<li
<Tooltip
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
content={status.title}
>
<DownloadBlock downloadItem={status} />
</li>
<li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} />
</li>
</Tooltip>
))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
<li
<Tooltip
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
content={status.title}
>
<DownloadBlock downloadItem={status} is4k />
</li>
<li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} is4k />
</li>
</Tooltip>
))}
</ul>
</div>
@@ -272,11 +277,16 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`}
>
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
<img
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
<Tooltip
key={`watch-user-${user.id}`}
content={user.displayName}
>
<img
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
</Tooltip>
</a>
</Link>
))}
@@ -393,11 +403,16 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`}
>
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
<img
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
<Tooltip
key={`watch-user-${user.id}`}
content={user.displayName}
>
<img
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
</Tooltip>
</a>
</Link>
))}
@@ -453,22 +468,23 @@ const ManageSlideOver = ({
{intl.formatMessage(messages.manageModalAdvanced)}
</h3>
<div className="space-y-2">
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<Button
onClick={() => markAvailable()}
className="w-full"
buttonType="success"
>
<CheckCircleIcon />
<span>
{intl.formatMessage(
mediaType === 'movie'
? messages.markavailable
: messages.markallseasonsavailable
)}
</span>
</Button>
)}
{data?.mediaInfo.status !== MediaStatus.AVAILABLE &&
settings.currentSettings.seriesEnabled && (
<Button
onClick={() => markAvailable()}
className="w-full"
buttonType="success"
>
<CheckCircleIcon />
<span>
{intl.formatMessage(
mediaType === 'movie'
? messages.markavailable
: messages.markallseasonsavailable
)}
</span>
</Button>
)}
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled && (
<Button

View File

@@ -98,6 +98,7 @@ const MediaSlider = ({
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
@@ -112,6 +113,7 @@ const MediaSlider = ({
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}

View File

@@ -2,6 +2,7 @@ import RTAudFresh from '@app/assets/rt_aud_fresh.svg';
import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
import RTFresh from '@app/assets/rt_fresh.svg';
import RTRotten from '@app/assets/rt_rotten.svg';
import ImdbLogo from '@app/assets/services/imdb.svg';
import TmdbLogo from '@app/assets/tmdb_logo.svg';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
@@ -26,6 +27,7 @@ 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';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import {
ArrowRightCircleIcon,
CloudIcon,
@@ -39,7 +41,7 @@ import {
ChevronDoubleDownIcon,
ChevronDoubleUpIcon,
} from '@heroicons/react/24/solid';
import type { RTRating } from '@server/api/rottentomatoes';
import { type RatingResponse } from '@server/api/ratings';
import { IssueStatus } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
@@ -85,6 +87,7 @@ const messages = defineMessages({
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
rtaudiencescore: 'Rotten Tomatoes Audience Score',
tmdbuserscore: 'TMDB User Score',
imdbuserscore: 'IMDB User Score',
});
interface MovieDetailsProps {
@@ -110,10 +113,17 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
mutate: revalidate,
} = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, {
fallbackData: movie,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: movie?.mediaInfo?.downloadStatus,
downloadStatus4k: movie?.mediaInfo?.downloadStatus4k,
},
15000
),
});
const { data: ratingData } = useSWR<RTRating>(
`/api/v1/movie/${router.query.movieId}/ratings`
const { data: ratingData } = useSWR<RatingResponse>(
`/api/v1/movie/${router.query.movieId}/ratingscombined`
);
const sortedCrew = useMemo(
@@ -144,6 +154,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const mediaLinks: PlayButtonLink[] = [];
if (
settings.currentSettings.movieEnabled &&
plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
@@ -304,16 +315,28 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</div>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={data.title}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie"
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
/>
{settings.currentSettings.movieEnabled &&
hasPermission(
[
Permission.MANAGE_REQUESTS,
Permission.REQUEST,
Permission.REQUEST_MOVIE,
],
{
type: 'or',
}
) && (
<StatusBadge
status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={data.title}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie"
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
/>
)}
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[
@@ -369,7 +392,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
tmdbId={data.id}
onUpdate={() => revalidate()}
/>
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
{((settings.currentSettings.movieEnabled &&
data.mediaInfo?.status === MediaStatus.AVAILABLE) ||
(settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
@@ -503,44 +527,62 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
)}
<div className="media-facts">
{(!!data.voteCount ||
(ratingData?.criticsRating && !!ratingData?.criticsScore) ||
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
(ratingData?.rt?.criticsRating &&
!!ratingData?.rt?.criticsScore) ||
(ratingData?.rt?.audienceRating &&
!!ratingData?.rt?.audienceScore) ||
ratingData?.imdb?.criticsScore) && (
<div className="media-ratings">
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
<Tooltip
content={intl.formatMessage(messages.rtcriticsscore)}
>
{ratingData?.rt?.criticsRating &&
!!ratingData?.rt?.criticsScore && (
<Tooltip
content={intl.formatMessage(messages.rtcriticsscore)}
>
<a
href={ratingData.rt.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.rt.criticsRating === 'Rotten' ? (
<RTRotten className="w-6" />
) : (
<RTFresh className="w-6" />
)}
<span>{ratingData.rt.criticsScore}%</span>
</a>
</Tooltip>
)}
{ratingData?.rt?.audienceRating &&
!!ratingData?.rt?.audienceScore && (
<Tooltip
content={intl.formatMessage(messages.rtaudiencescore)}
>
<a
href={ratingData.rt.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.rt.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6" />
) : (
<RTAudFresh className="w-6" />
)}
<span>{ratingData.rt.audienceScore}%</span>
</a>
</Tooltip>
)}
{ratingData?.imdb?.criticsScore && (
<Tooltip content={intl.formatMessage(messages.imdbuserscore)}>
<a
href={ratingData.url}
href={ratingData.imdb.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="w-6" />
) : (
<RTFresh className="w-6" />
)}
<span>{ratingData.criticsScore}%</span>
</a>
</Tooltip>
)}
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
<Tooltip
content={intl.formatMessage(messages.rtaudiencescore)}
>
<a
href={ratingData.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6" />
) : (
<RTAudFresh className="w-6" />
)}
<span>{ratingData.audienceScore}%</span>
<ImdbLogo className="mr-1 w-6" />
<span>{ratingData.imdb.criticsScore}</span>
</a>
</Tooltip>
)}
@@ -789,7 +831,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
tmdbId={data.id}
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
rtUrl={ratingData?.rt?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
/>
</div>

View File

@@ -159,17 +159,19 @@ const NotificationTypeSelector = ({
{ type: 'or' }
) ||
// Cannot submit non-4K movie requests OR has Auto-Approve perms for non-4K movies
((!hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) ||
((!settings.currentSettings.movieEnabled ||
!hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_MOVIE],
{ type: 'or' }
)) &&
// Cannot submit non-4K series requests OR has Auto-Approve perms for non-4K series
(!hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
}) ||
(!settings.currentSettings.seriesEnabled ||
!hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_TV],
{ type: 'or' }

View File

@@ -145,6 +145,7 @@ const PersonDetails = () => {
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
status4k={media.mediaInfo?.status4k}
canExpand
/>
{media.character && (
@@ -185,6 +186,7 @@ const PersonDetails = () => {
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
status4k={media.mediaInfo?.status}
canExpand
/>
{media.job && (

View File

@@ -1,45 +0,0 @@
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/router';
import PR from 'pulltorefreshjs';
import { useEffect } from 'react';
import ReactDOMServer from 'react-dom/server';
const PullToRefresh = () => {
const router = useRouter();
useEffect(() => {
PR.init({
mainElement: '#pull-to-refresh',
onRefresh() {
router.reload();
},
iconArrow: ReactDOMServer.renderToString(
<div className="p-2">
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
</div>
),
iconRefreshing: ReactDOMServer.renderToString(
<div
className="animate-spin p-2"
style={{ animationDirection: 'reverse' }}
>
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
</div>
),
instructionsPullToRefresh: ReactDOMServer.renderToString(<div />),
instructionsReleaseToRefresh: ReactDOMServer.renderToString(<div />),
instructionsRefreshing: ReactDOMServer.renderToString(<div />),
distReload: 60,
distIgnore: 15,
shouldPullToRefresh: () =>
!window.scrollY && document.body.style.overflow !== 'hidden',
});
return () => {
PR.destroyAll();
};
}, [router]);
return <div id="pull-to-refresh"></div>;
};
export default PullToRefresh;

View File

@@ -122,7 +122,7 @@ const RegionSelector = ({
<Transition
show={open}
leave="transition ease-in duration-100"
leave="transition-opacity ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"

View File

@@ -273,7 +273,9 @@ const RequestButton = ({
: Permission.REQUEST_TV,
],
{ type: 'or' }
)
) &&
((settings.currentSettings.movieEnabled && mediaType === 'movie') ||
(settings.currentSettings.seriesEnabled && mediaType === 'tv'))
) {
buttons.push({
id: 'request',
@@ -292,7 +294,8 @@ const RequestButton = ({
}) &&
media &&
media.status !== MediaStatus.AVAILABLE &&
!isShowComplete
!isShowComplete &&
settings.currentSettings.seriesEnabled
) {
buttons.push({
id: 'request-more',

View File

@@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { withProperties } from '@app/utils/typeHelpers';
import {
ArrowPathIcon,
@@ -220,6 +221,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}` : null
);
@@ -229,6 +231,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
mutate: revalidate,
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
});
const { plexUrl, plexUrl4k } = useDeepLinks({

View File

@@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import {
ArrowPathIcon,
CheckIcon,
@@ -293,6 +294,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
`/api/v1/request/${request.id}`,
{
fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
}
);

View File

@@ -582,10 +582,10 @@ const AdvancedRequester = ({
<Transition
show={open}
enter="transition ease-in duration-300"
enter="transition-opacity ease-in duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-100"
leave="transition-opacity ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg"

View File

@@ -324,7 +324,7 @@ const CollectionRequestModal = ({
aria-hidden="true"
className={`${
isAllParts() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
@@ -389,7 +389,7 @@ const CollectionRequestModal = ({
isSelectedPart(part.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>

View File

@@ -540,7 +540,7 @@ const TvRequestModal = ({
aria-hidden="true"
className={`${
isAllSeasons() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
@@ -631,7 +631,7 @@ const TvRequestModal = ({
isSelectedSeason(season.seasonNumber)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>

View File

@@ -29,10 +29,10 @@ const RequestModal = ({
return (
<Transition
as="div"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}

View File

@@ -169,15 +169,19 @@ export const GenreSelector = ({
loadDefaultGenre();
}, [defaultValue, type]);
const loadGenreOptions = async () => {
const loadGenreOptions = async (inputValue: string) => {
const results = await axios.get<GenreSliderItem[]>(
`/api/v1/discover/genreslider/${type}`
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
return results.data
.map((result) => ({
label: result.name,
value: result.id,
}))
.filter(({ label }) =>
label.toLowerCase().includes(inputValue.toLowerCase())
);
};
return (
@@ -305,7 +309,9 @@ export const WatchProviderSelector = ({
useEffect(() => {
onChange(watchRegion, activeProvider);
}, [activeProvider, watchRegion, onChange]);
// removed onChange as a dependency as we only need to call it when the value(s) change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeProvider, watchRegion]);
const orderedData = useMemo(() => {
if (!data) {
@@ -344,7 +350,7 @@ export const WatchProviderSelector = ({
<SmallLoadingSpinner />
) : (
<div className="grid">
<div className="grid grid-cols-6 gap-2">
<div className="provider-icons grid gap-2">
{initialProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id);
return (
@@ -353,7 +359,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 ${
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -386,7 +392,7 @@ export const WatchProviderSelector = ({
})}
</div>
{showMore && otherProviders.length > 0 && (
<div className="relative top-2 grid grid-cols-6 gap-2">
<div className="provider-icons relative top-2 grid gap-2">
{otherProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id);
return (
@@ -395,7 +401,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -431,6 +437,7 @@ export const WatchProviderSelector = ({
{otherProviders.length > 0 && (
<button
className="relative top-4 flex items-center justify-center space-x-2 text-sm text-gray-400 transition hover:text-gray-200"
type="button"
onClick={() => setShowMore(!showMore)}
>
<div className="h-0.5 flex-1 bg-gray-600" />

View File

@@ -32,7 +32,7 @@ const LibraryItem = ({ isEnabled, name, onToggle }: LibraryItemProps) => {
aria-hidden="true"
className={`${
isEnabled ? 'translate-x-5' : 'translate-x-0'
} relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out`}
} relative inline-block h-5 w-5 rounded-full bg-white shadow transition duration-200 ease-in-out`}
>
<span
className={`${

View File

@@ -39,6 +39,9 @@ const defaultPayload = {
requestedBy_email: '{{requestedBy_email}}',
requestedBy_username: '{{requestedBy_username}}',
requestedBy_avatar: '{{requestedBy_avatar}}',
requestedBy_settings_discordId: '{{requestedBy_settings_discordId}}',
requestedBy_settings_telegramChatId:
'{{requestedBy_settings_telegramChatId}}',
},
'{{issue}}': {
issue_id: '{{issue_id}}',
@@ -47,12 +50,18 @@ const defaultPayload = {
reportedBy_email: '{{reportedBy_email}}',
reportedBy_username: '{{reportedBy_username}}',
reportedBy_avatar: '{{reportedBy_avatar}}',
reportedBy_settings_discordId: '{{reportedBy_settings_discordId}}',
reportedBy_settings_telegramChatId:
'{{reportedBy_settings_telegramChatId}}',
},
'{{comment}}': {
comment_message: '{{comment_message}}',
commentedBy_email: '{{commentedBy_email}}',
commentedBy_username: '{{commentedBy_username}}',
commentedBy_avatar: '{{commentedBy_avatar}}',
commentedBy_settings_discordId: '{{commentedBy_settings_discordId}}',
commentedBy_settings_telegramChatId:
'{{commentedBy_settings_telegramChatId}}',
},
'{{extra}}': [],
};

View File

@@ -57,6 +57,9 @@ const messages = defineMessages({
testFirstTags: 'Test connection to load tags',
tags: 'Tags',
enableSearch: 'Enable Automatic Search',
tagRequests: 'Tag Requests',
tagRequestsInfo:
"Automatically add an additional tag with the requester's user ID & display name",
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
@@ -214,10 +217,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
as="div"
appear
show
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacuty-100"
leave="transition ease-in-out duration-300 transform opacity-100"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@@ -238,6 +241,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
externalUrl: radarr?.externalUrl,
syncEnabled: radarr?.syncEnabled ?? false,
enableSearch: !radarr?.preventSearch,
tagRequests: radarr?.tagRequests ?? false,
}}
validationSchema={RadarrSettingsSchema}
onSubmit={async (values) => {
@@ -263,6 +267,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
};
if (!radarr) {
await axios.post('/api/v1/settings/radarr', submission);
@@ -713,6 +718,21 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="tagRequests" className="checkbox-label">
{intl.formatMessage(messages.tagRequests)}
<span className="label-tip">
{intl.formatMessage(messages.tagRequestsInfo)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="tagRequests"
name="tagRequests"
/>
</div>
</div>
</div>
</Modal>
);

View File

@@ -63,10 +63,10 @@ const Release = ({ currentVersion, release, isLatest }: ReleaseProps) => {
<div className="flex w-full flex-col space-y-3 rounded-md bg-gray-800 px-4 py-2 shadow-md ring-1 ring-gray-700 sm:flex-row sm:space-y-0 sm:space-x-3">
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={isModalOpen}

View File

@@ -53,6 +53,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan',
'plex-watchlist-sync': 'Plex Watchlist Sync',
'availability-sync': 'Media Availability Sync',
'radarr-scan': 'Radarr Scan',
'sonarr-scan': 'Sonarr Scan',
'download-sync': 'Download Sync',
@@ -67,6 +68,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
editJobScheduleSelectorMinutes:
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
editJobScheduleSelectorSeconds:
'Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}',
imagecache: 'Image Cache',
imagecacheDescription:
'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
@@ -78,7 +81,7 @@ interface Job {
id: JobId;
name: string;
type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
nextExecutionTime: string;
running: boolean;
@@ -89,10 +92,11 @@ type JobModalState = {
job?: Job;
scheduleHours: number;
scheduleMinutes: number;
scheduleSeconds: number;
};
type JobModalAction =
| { type: 'set'; hours?: number; minutes?: number }
| { type: 'set'; hours?: number; minutes?: number; seconds?: number }
| {
type: 'close';
}
@@ -115,6 +119,7 @@ const jobModalReducer = (
job: action.job,
scheduleHours: 1,
scheduleMinutes: 5,
scheduleSeconds: 30,
};
case 'set':
@@ -122,6 +127,7 @@ const jobModalReducer = (
...state,
scheduleHours: action.hours ?? state.scheduleHours,
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
};
}
};
@@ -149,6 +155,7 @@ const SettingsJobs = () => {
isOpen: false,
scheduleHours: 1,
scheduleMinutes: 5,
scheduleSeconds: 30,
});
const [isSaving, setIsSaving] = useState(false);
@@ -200,9 +207,11 @@ const SettingsJobs = () => {
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
try {
if (jobModalState.job?.interval === 'short') {
if (jobModalState.job?.interval === 'seconds') {
jobScheduleCron.splice(0, 2, `*/${jobModalState.scheduleSeconds}`, '*');
} else if (jobModalState.job?.interval === 'minutes') {
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
} else if (jobModalState.job?.interval === 'long') {
} else if (jobModalState.job?.interval === 'hours') {
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
} else {
// jobs with interval: fixed should not be editable
@@ -244,10 +253,10 @@ const SettingsJobs = () => {
/>
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={jobModalState.isOpen}
@@ -286,7 +295,30 @@ const SettingsJobs = () => {
{intl.formatMessage(messages.editJobSchedulePrompt)}
</label>
<div className="form-input-area">
{jobModalState.job?.interval === 'short' ? (
{jobModalState.job?.interval === 'seconds' ? (
<select
name="jobScheduleSeconds"
className="inline"
value={jobModalState.scheduleSeconds}
onChange={(e) =>
dispatch({
type: 'set',
seconds: Number(e.target.value),
})
}
>
{[30, 45, 60].map((v) => (
<option value={v} key={`jobScheduleSeconds-${v}`}>
{intl.formatMessage(
messages.editJobScheduleSelectorSeconds,
{
jobScheduleSeconds: v,
}
)}
</option>
))}
</select>
) : jobModalState.job?.interval === 'minutes' ? (
<select
name="jobScheduleMinutes"
className="inline"

View File

@@ -143,10 +143,10 @@ const SettingsLogs = () => {
/>
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
appear

View File

@@ -247,10 +247,10 @@ const SettingsServices = () => {
<Transition
as={Fragment}
show={deleteServerModal.open}
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacuty-100"
leave="transition ease-in-out duration-300 transform opacity-100"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>

View File

@@ -43,6 +43,8 @@ const messages = defineMessages({
qualityprofile: 'Quality Profile',
languageprofile: 'Language Profile',
rootfolder: 'Root Folder',
seriesType: 'Series Type',
animeSeriesType: 'Anime Series Type',
animequalityprofile: 'Anime Quality Profile',
animelanguageprofile: 'Anime Language Profile',
animerootfolder: 'Anime Root Folder',
@@ -62,6 +64,9 @@ const messages = defineMessages({
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
enableSearch: 'Enable Automatic Search',
tagRequests: 'Tag Requests',
tagRequestsInfo:
"Automatically add an additional tag with the requester's user ID & display name",
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
@@ -223,10 +228,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
as="div"
appear
show
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacuty-100"
leave="transition ease-in-out duration-300 transform opacity-100"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@@ -241,6 +246,8 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
activeProfileId: sonarr?.activeProfileId,
activeLanguageProfileId: sonarr?.activeLanguageProfileId,
rootFolder: sonarr?.activeDirectory,
seriesType: sonarr?.seriesType,
animeSeriesType: sonarr?.animeSeriesType,
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
activeAnimeLanguageProfileId: sonarr?.activeAnimeLanguageProfileId,
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
@@ -252,6 +259,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
externalUrl: sonarr?.externalUrl,
syncEnabled: sonarr?.syncEnabled ?? false,
enableSearch: !sonarr?.preventSearch,
tagRequests: sonarr?.tagRequests ?? false,
}}
validationSchema={SonarrSettingsSchema}
onSubmit={async (values) => {
@@ -276,6 +284,8 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
: undefined,
activeProfileName: profileName,
activeDirectory: values.rootFolder,
seriesType: values.seriesType,
animeSeriesType: values.animeSeriesType,
activeAnimeProfileId: values.activeAnimeProfileId
? Number(values.activeAnimeProfileId)
: undefined,
@@ -292,6 +302,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
};
if (!sonarr) {
await axios.post('/api/v1/settings/sonarr', submission);
@@ -532,6 +543,27 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="seriesType" className="text-label">
{intl.formatMessage(messages.seriesType)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="seriesType"
name="seriesType"
disabled={!isValidated || isTesting}
>
<option value="standard">Standard</option>
<option value="daily">Daily</option>
</Field>
</div>
</div>
{errors.seriesType && touched.seriesType && (
<div className="error">{errors.seriesType}</div>
)}
</div>
<div className="form-row">
<label htmlFor="activeProfileId" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
@@ -718,6 +750,27 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="animeSeriesType" className="text-label">
{intl.formatMessage(messages.animeSeriesType)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="animeSeriesType"
name="animeSeriesType"
disabled={!isValidated || isTesting}
>
<option value="standard">Standard</option>
<option value="anime">Anime</option>
</Field>
</div>
</div>
{errors.animeSeriesType && touched.animeSeriesType && (
<div className="error">{errors.animeSeriesType}</div>
)}
</div>
<div className="form-row">
<label htmlFor="activeAnimeProfileId" className="text-label">
{intl.formatMessage(messages.animequalityprofile)}
@@ -960,6 +1013,21 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="tagRequests" className="checkbox-label">
{intl.formatMessage(messages.tagRequests)}
<span className="label-tip">
{intl.formatMessage(messages.tagRequestsInfo)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="tagRequests"
name="tagRequests"
/>
</div>
</div>
</div>
</Modal>
);

View File

@@ -156,6 +156,7 @@ const Slider = ({
}`}
onClick={() => slide(Direction.LEFT)}
disabled={scrollPos.isStart}
type="button"
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
@@ -165,6 +166,7 @@ const Slider = ({
}`}
onClick={() => slide(Direction.RIGHT)}
disabled={scrollPos.isEnd}
type="button"
>
<ChevronRightIcon className="h-6 w-6" />
</button>

View File

@@ -73,7 +73,10 @@ const StatusBadge = ({
type: 'or',
}
) &&
(!is4k ||
((!is4k &&
(mediaType === 'movie'
? settings.currentSettings.movieEnabled
: settings.currentSettings.seriesEnabled)) ||
(mediaType === 'movie'
? settings.currentSettings.movie4kEnabled
: settings.currentSettings.series4kEnabled))
@@ -166,11 +169,11 @@ const StatusBadge = ({
</span>
{inProgress && (
<>
{mediaType === 'tv' && (
{mediaType === 'tv' && downloadItem[0].episode && (
<span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode?.seasonNumber,
episodeNumber: downloadItem[0].episode?.episodeNumber,
seasonNumber: downloadItem[0].episode.seasonNumber,
episodeNumber: downloadItem[0].episode.episodeNumber,
})}
</span>
)}
@@ -219,11 +222,11 @@ const StatusBadge = ({
</span>
{inProgress && (
<>
{mediaType === 'tv' && (
{mediaType === 'tv' && downloadItem[0].episode && (
<span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode?.seasonNumber,
episodeNumber: downloadItem[0].episode?.episodeNumber,
seasonNumber: downloadItem[0].episode.seasonNumber,
episodeNumber: downloadItem[0].episode.episodeNumber,
})}
</span>
)}
@@ -272,11 +275,11 @@ const StatusBadge = ({
</span>
{inProgress && (
<>
{mediaType === 'tv' && (
{mediaType === 'tv' && downloadItem[0].episode && (
<span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode?.seasonNumber,
episodeNumber: downloadItem[0].episode?.episodeNumber,
seasonNumber: downloadItem[0].episode.seasonNumber,
episodeNumber: downloadItem[0].episode.episodeNumber,
})}
</span>
)}

View File

@@ -44,10 +44,10 @@ const StatusChecker = () => {
return (
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
appear

View File

@@ -59,6 +59,7 @@ const TmdbTitleCard = ({
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
@@ -71,6 +72,7 @@ const TmdbTitleCard = ({
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}

View File

@@ -6,6 +6,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 useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { withProperties } from '@app/utils/typeHelpers';
@@ -23,9 +24,10 @@ interface TitleCardProps {
summary?: string;
year?: string;
title: string;
userScore: number;
userScore?: number;
mediaType: MediaType;
status?: MediaStatus;
status4k?: MediaStatus;
canExpand?: boolean;
inProgress?: boolean;
}
@@ -37,6 +39,7 @@ const TitleCard = ({
year,
title,
status,
status4k,
mediaType,
inProgress = false,
canExpand = false,
@@ -46,8 +49,11 @@ const TitleCard = ({
const { hasPermission } = useUser();
const [isUpdating, setIsUpdating] = useState(false);
const [currentStatus, setCurrentStatus] = useState(status);
const [currentStatus4k, setCurrentStatus4k] = useState(status4k);
const [showDetail, setShowDetail] = useState(false);
const [showRequestModal, setShowRequestModal] = useState(false);
const [showRequestModal4k, setShowRequestModal4k] = useState(false);
const settings = useSettings();
// Just to get the year from the date
if (year) {
@@ -56,7 +62,8 @@ const TitleCard = ({
useEffect(() => {
setCurrentStatus(status);
}, [status]);
setCurrentStatus4k(status4k);
}, [status, status4k]);
const requestComplete = useCallback((newStatus: MediaStatus) => {
setCurrentStatus(newStatus);
@@ -70,13 +77,44 @@ const TitleCard = ({
const closeModal = useCallback(() => setShowRequestModal(false), []);
const showRequestButton = hasPermission(
[
Permission.REQUEST,
mediaType === 'movie' ? Permission.REQUEST_MOVIE : Permission.REQUEST_TV,
],
{ type: 'or' }
);
const requestComplete4k = useCallback((newStatus: MediaStatus) => {
setCurrentStatus4k(newStatus);
setShowRequestModal4k(false);
}, []);
const closeModal4k = useCallback(() => setShowRequestModal4k(false), []);
const showRequestButton =
hasPermission(
[
Permission.REQUEST,
mediaType === 'movie' || mediaType === 'collection'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
{ type: 'or' }
) &&
((settings.currentSettings.movieEnabled && mediaType === 'movie') ||
(settings.currentSettings.seriesEnabled && mediaType === 'tv'));
const showRequestButton4k =
hasPermission(
[
Permission.REQUEST_4K,
mediaType === 'movie' || mediaType === 'collection'
? Permission.REQUEST_4K_MOVIE
: Permission.REQUEST_4K_TV,
],
{ type: 'or' }
) &&
((settings.currentSettings.movie4kEnabled && mediaType === 'movie') ||
(settings.currentSettings.series4kEnabled && mediaType === 'tv'));
const isRequestable =
(showRequestButton &&
(!currentStatus || currentStatus === MediaStatus.UNKNOWN)) ||
(showRequestButton4k &&
(!currentStatus4k || currentStatus4k === MediaStatus.UNKNOWN));
return (
<div
@@ -86,11 +124,32 @@ const TitleCard = ({
<RequestModal
tmdbId={id}
show={showRequestModal}
type={mediaType === 'movie' ? 'movie' : 'tv'}
type={
mediaType === 'movie'
? 'movie'
: mediaType === 'collection'
? 'collection'
: 'tv'
}
onComplete={requestComplete}
onUpdating={requestUpdating}
onCancel={closeModal}
/>
<RequestModal
tmdbId={id}
show={showRequestModal4k}
type={
mediaType === 'movie'
? 'movie'
: mediaType === 'collection'
? 'collection'
: 'tv'
}
onComplete={requestComplete4k}
onUpdating={requestUpdating}
onCancel={closeModal4k}
is4k
/>
<div
className={`relative transform-gpu cursor-default overflow-hidden rounded-xl bg-gray-800 bg-cover outline-none ring-1 transition duration-300 ${
showDetail
@@ -130,7 +189,7 @@ const TitleCard = ({
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
mediaType === 'movie'
mediaType === 'movie' || mediaType === 'collection'
? 'border-blue-500 bg-blue-600'
: 'border-purple-600 bg-purple-600'
}`}
@@ -138,26 +197,37 @@ const TitleCard = ({
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{mediaType === 'movie'
? intl.formatMessage(globalMessages.movie)
: mediaType === 'collection'
? intl.formatMessage(globalMessages.collection)
: intl.formatMessage(globalMessages.tvshow)}
</div>
</div>
{currentStatus && (
<div className="pointer-events-none z-40 flex items-center">
<div className="pointer-events-none z-40 flex items-center">
{currentStatus && currentStatus !== MediaStatus.UNKNOWN ? (
<StatusBadgeMini
status={currentStatus}
inProgress={inProgress}
shrink
/>
</div>
)}
) : (
currentStatus4k &&
currentStatus4k !== MediaStatus.UNKNOWN && (
<StatusBadgeMini
status={currentStatus4k}
inProgress={inProgress}
shrink
/>
)
)}
</div>
</div>
<Transition
as={Fragment}
show={isUpdating}
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in-out duration-300 transform opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@@ -168,16 +238,26 @@ const TitleCard = ({
<Transition
as={Fragment}
show={!image || showDetail || showRequestModal}
enter="transition transform opacity-0"
show={
!image || showDetail || showRequestModal || showRequestModal4k
}
enter="transition-opacity"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition transform opacity-100"
leave="transition-opacity"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 overflow-hidden rounded-xl">
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
<Link
href={
mediaType === 'movie'
? `/movie/${id}`
: mediaType === 'collection'
? `/collection/${id}`
: `/tv/${id}`
}
>
<a
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"
style={{
@@ -188,10 +268,7 @@ const TitleCard = ({
<div className="flex h-full w-full items-end">
<div
className={`px-2 text-white ${
!showRequestButton ||
(currentStatus && currentStatus !== MediaStatus.UNKNOWN)
? 'pb-2'
: 'pb-11'
!isRequestable ? 'pb-2' : 'pb-11'
}`}
>
{year && (
@@ -214,12 +291,7 @@ const TitleCard = ({
<div
className="whitespace-normal text-xs"
style={{
WebkitLineClamp:
!showRequestButton ||
(currentStatus &&
currentStatus !== MediaStatus.UNKNOWN)
? 5
: 3,
WebkitLineClamp: !isRequestable ? 5 : 3,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
@@ -235,20 +307,39 @@ const TitleCard = ({
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
{showRequestButton &&
(!currentStatus || currentStatus === MediaStatus.UNKNOWN) && (
(!currentStatus || currentStatus === MediaStatus.UNKNOWN) ? (
<Button
buttonType="primary"
buttonSize="sm"
onClick={(e) => {
e.preventDefault();
setShowRequestModal(true);
}}
className="h-7 w-full"
>
<ArrowDownTrayIcon />
<span>{intl.formatMessage(globalMessages.request)}</span>
</Button>
) : (
showRequestButton4k &&
(!currentStatus4k ||
currentStatus4k === MediaStatus.UNKNOWN) && (
<Button
buttonType="primary"
buttonSize="sm"
onClick={(e) => {
e.preventDefault();
setShowRequestModal(true);
setShowRequestModal4k(true);
}}
className="h-7 w-full"
>
<ArrowDownTrayIcon />
<span>{intl.formatMessage(globalMessages.request)}</span>
<span>
{intl.formatMessage(globalMessages.request4k)}
</span>
</Button>
)}
)
)}
</div>
</div>
</Transition>

View File

@@ -30,6 +30,7 @@ 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';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { Disclosure, Transition } from '@headlessui/react';
import {
ArrowRightCircleIcon,
@@ -39,7 +40,7 @@ import {
PlayIcon,
} from '@heroicons/react/24/outline';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import type { RTRating } from '@server/api/rottentomatoes';
import type { RTRating } from '@server/api/rating/rottentomatoes';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { IssueStatus } from '@server/constants/issue';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
@@ -109,6 +110,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
mutate: revalidate,
} = useSWR<TvDetailsType>(`/api/v1/tv/${router.query.tvId}`, {
fallbackData: tv,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: tv?.mediaInfo?.downloadStatus,
downloadStatus4k: tv?.mediaInfo?.downloadStatus4k,
},
15000
),
});
const { data: ratingData } = useSWR<RTRating>(
@@ -141,7 +149,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
const mediaLinks: PlayButtonLink[] = [];
if (plexUrl) {
if (
settings.currentSettings.seriesEnabled &&
plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.playonplex),
url: plexUrl,
@@ -329,16 +343,28 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
</div>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={data.name}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="tv"
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
/>
{settings.currentSettings.seriesEnabled &&
hasPermission(
[
Permission.MANAGE_REQUESTS,
Permission.REQUEST,
Permission.REQUEST_TV,
],
{
type: 'or',
}
) && (
<StatusBadge
status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={data.name}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="tv"
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
/>
)}
{settings.currentSettings.series4kEnabled &&
hasPermission(
[
@@ -396,8 +422,12 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
isShowComplete={isComplete}
is4kShowComplete={is4kComplete}
/>
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE ||
{((settings.currentSettings.seriesEnabled &&
hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
}) &&
(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
data?.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE)) ||
(settings.currentSettings.series4kEnabled &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
@@ -516,6 +546,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
.reverse()
.filter((season) => season.seasonNumber !== 0)
.map((season) => {
const showNon4k =
settings.currentSettings.seriesEnabled &&
hasPermission(
[
Permission.MANAGE_REQUESTS,
Permission.REQUEST,
Permission.REQUEST_TV,
],
{
type: 'or',
}
);
const show4k =
settings.currentSettings.series4kEnabled &&
hasPermission(
@@ -580,65 +622,75 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
</div>
{((!mSeason &&
request?.status === MediaRequestStatus.APPROVED) ||
mSeason?.status === MediaStatus.PROCESSING) && (
<>
<div className="hidden md:flex">
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
</div>
<div className="flex md:hidden">
<StatusBadgeMini
status={MediaStatus.PROCESSING}
/>
</div>
</>
)}
mSeason?.status === MediaStatus.PROCESSING) &&
showNon4k && (
<>
<div className="hidden md:flex">
<Badge badgeType="primary">
{intl.formatMessage(
globalMessages.requested
)}
</Badge>
</div>
<div className="flex md:hidden">
<StatusBadgeMini
status={MediaStatus.PROCESSING}
/>
</div>
</>
)}
{((!mSeason &&
request?.status === MediaRequestStatus.PENDING) ||
mSeason?.status === MediaStatus.PENDING) && (
<>
<div className="hidden md:flex">
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
</div>
<div className="flex md:hidden">
<StatusBadgeMini status={MediaStatus.PENDING} />
</div>
</>
)}
mSeason?.status === MediaStatus.PENDING) &&
showNon4k && (
<>
<div className="hidden md:flex">
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
</div>
<div className="flex md:hidden">
<StatusBadgeMini
status={MediaStatus.PENDING}
/>
</div>
</>
)}
{mSeason?.status ===
MediaStatus.PARTIALLY_AVAILABLE && (
<>
<div className="hidden md:flex">
<Badge badgeType="success">
{intl.formatMessage(
globalMessages.partiallyavailable
)}
</Badge>
</div>
<div className="flex md:hidden">
<StatusBadgeMini
status={MediaStatus.PARTIALLY_AVAILABLE}
/>
</div>
</>
)}
{mSeason?.status === MediaStatus.AVAILABLE && (
<>
<div className="hidden md:flex">
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
</div>
<div className="flex md:hidden">
<StatusBadgeMini
status={MediaStatus.AVAILABLE}
/>
</div>
</>
)}
MediaStatus.PARTIALLY_AVAILABLE &&
showNon4k && (
<>
<div className="hidden md:flex">
<Badge badgeType="success">
{intl.formatMessage(
globalMessages.partiallyavailable
)}
</Badge>
</div>
<div className="flex md:hidden">
<StatusBadgeMini
status={MediaStatus.PARTIALLY_AVAILABLE}
/>
</div>
</>
)}
{mSeason?.status === MediaStatus.AVAILABLE &&
showNon4k && (
<>
<div className="hidden md:flex">
<Badge badgeType="success">
{intl.formatMessage(
globalMessages.available
)}
</Badge>
</div>
<div className="flex md:hidden">
<StatusBadgeMini
status={MediaStatus.AVAILABLE}
/>
</div>
</>
)}
{((!mSeason4k &&
request4k?.status ===
MediaRequestStatus.APPROVED) ||
@@ -727,18 +779,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
)}
<ChevronDownIcon
className={`${
open ? 'rotate-180 transform' : ''
open ? 'rotate-180' : ''
} h-6 w-6 text-gray-500`}
/>
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
enter="transition-opacity duration-100 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-75 ease-out"
leaveFrom="opacity-100"
leaveTo="opacity-0"
// Not sure why this transition is adding a margin without this here
style={{ margin: '0px' }}
>

View File

@@ -155,7 +155,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
aria-hidden="true"
className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
@@ -194,7 +194,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>

View File

@@ -228,10 +228,10 @@ const UserList = () => {
<PageTitle title={intl.formatMessage(messages.users)} />
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={deleteModal.isOpen}
@@ -257,10 +257,10 @@ const UserList = () => {
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={createModal.isOpen}
@@ -440,10 +440,10 @@ const UserList = () => {
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showBulkEditModal}
@@ -461,10 +461,10 @@ const UserList = () => {
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showImportModal}

View File

@@ -14,6 +14,7 @@ export type AvailableLocale =
| 'hu'
| 'it'
| 'ja'
| 'ko'
| 'lt'
| 'nb-NO'
| 'nl'
@@ -125,6 +126,10 @@ export const availableLanguages: AvailableLanguageObject = {
code: 'ja',
display: '日本語',
},
ko: {
code: 'ko',
display: '한국어',
},
'zh-TW': {
code: 'zh-TW',
display: '繁體中文',

View File

@@ -13,6 +13,8 @@ const defaultSettings = {
applicationUrl: '',
hideAvailable: false,
localLogin: true,
movieEnabled: false,
seriesEnabled: false,
movie4kEnabled: false,
series4kEnabled: false,
region: '',

View File

@@ -15,13 +15,20 @@ export const useLockBodyScroll = (
disabled?: boolean
): void => {
useEffect(() => {
const originalStyle = window.getComputedStyle(document.body).overflow;
const originalOverflowStyle = window.getComputedStyle(
document.body
).overflow;
const originalTouchActionStyle = window.getComputedStyle(
document.body
).touchAction;
if (isLocked && !disabled) {
document.body.style.overflow = 'hidden';
document.body.style.touchAction = 'none';
}
return () => {
if (!disabled) {
document.body.style.overflow = originalStyle;
document.body.style.overflow = originalOverflowStyle;
document.body.style.touchAction = originalTouchActionStyle;
}
};
}, [isLocked, disabled]);

View File

@@ -16,6 +16,7 @@ const globalMessages = defineMessages({
approved: 'Approved',
movie: 'Movie',
movies: 'Movies',
collection: 'Collection',
tvshow: 'Series',
tvshows: 'Series',
cancel: 'Cancel',

View File

@@ -1,17 +1,17 @@
{
"components.AppDataWarning.dockerVolumeMissingDescription": "<code>{appDataPath}</code>مجلد التثبيت لم يتم إعداده بشكل صحيح سيتم تنظيف جميع البيانات عند إيقاف او إعادة تشغيل حاوي التطبيق.",
"components.NotificationTypeSelector.mediarequestedDescription": "إرسال تنبيهات في حالة وصول طلبات جديدة تتطلب الموافقة.",
"components.NotificationTypeSelector.notificationTypes": "نوع التنبيه",
"components.NotificationTypeSelector.notificationTypes": "أنواع التنبيهات",
"components.NotificationTypeSelector.usermediaapprovedDescription": "تلقي تنبيهات عندما يتم الموافقة على طلبات المحتوى.",
"components.NotificationTypeSelector.usermediadeclinedDescription": "تلقي تنبيهات عندما يتم رفض الطلبات.",
"components.PermissionEdit.createissuesDescription": "إعطاء صلاحية للإبلاغ عن مشاكل.",
"components.PermissionEdit.managerequestsDescription": "إعطاء صلاحية بإدارة الطلبات. كل الطلبات المقدمة من قبل مستخدم يحمل هذا الإذن سيتم الموافقة عليها بشكل تلقائي.",
"components.PermissionEdit.autoapproveDescription": "موافقة تلقائية لكل طلبات المحتوى باستثناء جودة الفور كي.",
"components.PermissionEdit.request4kMoviesDescription": "إعطاء صلاحية بطلب أفلام بجودة ٤ك.",
"components.PermissionEdit.requestDescription": "إعطاء اصلاحية بطلب محتوى ليس بجودة ٤ك.",
"components.PermissionEdit.request4kMoviesDescription": "إعطاء صلاحيّة بطلب أفلام بجودة فور كي.",
"components.PermissionEdit.requestDescription": "إعطاء اصلاحية بطلب محتوى ليس بجودة فور كي.",
"components.RequestModal.QuotaDisplay.notenoughseasonrequests": "تم إستهلاك الحد الأقصى المسموح لك بطلب المواسم",
"components.RequestModal.AdvancedRequester.selecttags": "إختيار علامات \"Tags\"",
"components.RequestModal.AdvancedRequester.tags": "علامات\"Tags\"",
"components.RequestModal.AdvancedRequester.selecttags": "إختار علامات التعريف",
"components.RequestModal.AdvancedRequester.tags": "علامات التعريف",
"components.RequestModal.QuotaDisplay.quotaLink": "يمكنك الإطلاع على ملخص الحد الأقصى لطلباتك في <ProfileLink>صفحة ملف التعريف</ProfileLink>.",
"components.RequestModal.QuotaDisplay.requiredquota": "تحتاج على الأقل ان تملك <strong>{seasons}</strong> {seasons, plural, one {طلب موسم} other {طلبات مواسم}} متبقية/متبقّي من حدك الأقصى لتتمكن من طلب هذا المسلسل.",
"components.Discover.NetworkSlider.networks": "شبكات البث",
@@ -45,7 +45,7 @@
"components.ManageSlideOver.manageModalMedia4k": "محتوى جودة الفور كي",
"components.ManageSlideOver.playedby": "تم تشغيله بواسطة",
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {تشغيل} other {تشغيل}}",
"components.ManageSlideOver.tvshow": "مسلسلات",
"components.ManageSlideOver.tvshow": "مسلسل",
"components.MediaSlider.ShowMoreCard.seemore": "رؤية المزيد",
"components.MovieDetails.MovieCast.fullcast": "طاقم التمثيل",
"components.MovieDetails.MovieCrew.fullcrew": "طاقم العمل",
@@ -63,11 +63,11 @@
"components.Discover.DiscoverStudio.studioMovies": "{studio} أفلام",
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} مسلسلات",
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} مسلسلات",
"components.Discover.MovieGenreList.moviegenres": "نوع الفيلم",
"components.Discover.MovieGenreSlider.moviegenres": "نوع الفيلم",
"components.Discover.StudioSlider.studios": "استديوهات الإنتاج",
"components.Discover.TvGenreList.seriesgenres": "نوع المسلسل",
"components.Discover.TvGenreSlider.tvgenres": "نوع المسلسل",
"components.Discover.MovieGenreList.moviegenres": "أنواع الأفلام",
"components.Discover.MovieGenreSlider.moviegenres": "أنواع الأفلام",
"components.Discover.StudioSlider.studios": "إستديوهات الإنتاج",
"components.Discover.TvGenreList.seriesgenres": "أنواع المسلسلات",
"components.Discover.TvGenreSlider.tvgenres": "أنواع المسلسلات",
"components.Discover.discover": "إكتشف",
"components.Discover.popularmovies": "أفلام ذات شعبية",
"components.Discover.populartv": "مسلسلات ذات شعبية",
@@ -95,7 +95,7 @@
"components.IssueDetails.deleteissueconfirm": "هل أنت متأكد من حذف هذه المشكلة ؟",
"components.IssueDetails.episode": "حلقة {episodeNumber}",
"components.IssueDetails.issuepagetitle": "مشكلة",
"components.IssueDetails.issuetype": "نوع",
"components.IssueDetails.issuetype": "النوع",
"components.IssueDetails.lastupdated": "أخر تحديث",
"components.IssueDetails.leavecomment": "تعليق",
"components.IssueDetails.nocomments": "لا تعليقات.",
@@ -135,7 +135,7 @@
"components.IssueModal.CreateIssueModal.problemepisode": "الحلقة المعنيّة",
"components.IssueModal.CreateIssueModal.problemseason": "الموسم المعني",
"components.IssueModal.CreateIssueModal.providedetail": "رجاءا قم بتزويد شرح مفصل للمشكلة التي صادفتها.",
"components.IssueModal.CreateIssueModal.reportissue": "الإبلاغ عن مشكلة",
"components.IssueModal.CreateIssueModal.reportissue": "الإبلاغ عن مشكلةْ",
"components.IssueModal.CreateIssueModal.season": "موسم {seasonNumber}",
"components.IssueModal.CreateIssueModal.submitissue": "تأكيد المشكلة",
"components.IssueModal.CreateIssueModal.toastFailedCreate": "حدث خطأ ما أثناء تأكيد المشكلة.",
@@ -161,8 +161,8 @@
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {تأكيد} other {تأكيد}} behind",
"components.Layout.VersionStatus.outofdate": "غير محدّث",
"components.Login.email": "عنوان البريد الإلكتروني",
"components.ManageSlideOver.markavailable": "الإشارة بأنه متوفر",
"components.ManageSlideOver.movie": "فيلم",
"components.ManageSlideOver.markavailable": "الإشارة بتوفر هذا المحتوى",
"components.ManageSlideOver.movie": "فيلْم",
"components.ManageSlideOver.openarr": "فتح في {arr}",
"components.ManageSlideOver.mark4kavailable": "الإشارة بتوفّره بجودة فور كي",
"components.ManageSlideOver.markallseasons4kavailable": "الإشارة بأن كل المواسم متوفرة بجودة فور كي",
@@ -171,13 +171,13 @@
"components.ManageSlideOver.opentautulli": "فتح في تطبيق تاتولي (Tautulli)",
"components.ManageSlideOver.pastdays": "مضى من الأيام {days, number}",
"components.MovieDetails.mark4kavailable": "الإشارة بأنه متوفر بجودة فور كي",
"components.MovieDetails.markavailable": "الإشارة بالتوفر",
"components.MovieDetails.markavailable": "الإشارة بتوفر هذا المحتوى",
"components.MovieDetails.originallanguage": "اللغة الأصلية",
"components.MovieDetails.originaltitle": "الإسم الأصلي",
"components.MovieDetails.overviewunavailable": "النظرة العامة غير متوفرة.",
"components.MovieDetails.play4konplex": "تشغيل بجودة فور كي في بليكس",
"components.MovieDetails.playonplex": "تشغيل في بليكس",
"components.MovieDetails.productioncountries": "الإنتاج {countryCount, plural, one {الدولة} other {الدول}}",
"components.MovieDetails.productioncountries": "إنتاج {countryCount, plural, one {الدولة} other {الدول}}",
"components.MovieDetails.recommendations": "توصيات",
"components.MovieDetails.releasedate": "{releaseCount, plural, one {تاريخ الاصدار other {تواريخ الاصدار}}",
"components.MovieDetails.revenue": "العائد / الربح",
@@ -187,7 +187,7 @@
"components.MovieDetails.similar": "عناوين مشابهه",
"components.MovieDetails.overview": "نظرة عامة",
"components.MovieDetails.streamingproviders": "يعرض حاليا على",
"components.MovieDetails.studio": "{studioCount, plural, one {استديو} other {استديوهات}}",
"components.MovieDetails.studio": "{studioCount, plural, one {إستديو الإنتاج} other {إستديوهات الإنتاج}}",
"components.MovieDetails.viewfullcrew": "رؤية طاقم العمل",
"components.MovieDetails.watchtrailer": "شاهد الإعلان",
"components.NotificationTypeSelector.adminissuecommentDescription": "الحصول على تنبيهات عند تعليق المستخدمين على المشاكل.",
@@ -232,18 +232,18 @@
"components.PermissionEdit.autoapproveMoviesDescription": "موافقة تلقائية لكل طلبات الأفلام باستثناء جودة الفور كي.",
"components.PermissionEdit.autoapproveSeries": "موافقة تلقائية للمسلسلات",
"components.PermissionEdit.autoapproveSeriesDescription": "موافقة تلقائية لكل طلبات المسلسلات باستثناء جودة الفور كي.",
"components.PermissionEdit.createissues": "الإبلاغ عن مشكلة",
"components.PermissionEdit.createissues": "الإبلاغ عن وجود مشاكل",
"components.PermissionEdit.manageissues": "إدارة المشاكل",
"components.PermissionEdit.manageissuesDescription": "إعطاء صلاحية بإدارة المشاكل.",
"components.PermissionEdit.managerequests": "إدارة الطلبات",
"components.PermissionEdit.request": "طلب",
"components.PermissionEdit.request4k": "طلب بجودة ٤ك",
"components.PermissionEdit.request4kDescription": "اعطاء صلاحية بطلب محتوى بجودة ٤ك.",
"components.PermissionEdit.request4kMovies": "طلب أفلام بجودة ٤ك",
"components.PermissionEdit.request4kTv": "طلب مسلسلات بجودة ٤ك",
"components.PermissionEdit.request4kTvDescription": "إعطاء صلاحية بطلب مسلسلات بجودة ٤ك.",
"components.PermissionEdit.request4k": "طلب بجودة فور كيّ",
"components.PermissionEdit.request4kDescription": "اعطاء صلاحية بطلب محتوى بجودة فور كي.",
"components.PermissionEdit.request4kMovies": "طلب أفلام بجودة فور كي",
"components.PermissionEdit.request4kTv": "طلب مسلسلات بجودة فور كي",
"components.PermissionEdit.request4kTvDescription": "إعطاء صلاحية بطلب مسلسلات بجودة فور كي.",
"components.PermissionEdit.requestMovies": "طلب أفلام",
"components.PermissionEdit.requestMoviesDescription": "إعطاء صلاحية بطلب أفلام ليست بجودة ٤ك.",
"components.PermissionEdit.requestMoviesDescription": "إعطاء صلاحية بطلب أفلام ليست بجودة فور كي.",
"components.PermissionEdit.requestTv": "طلب مسلسلات",
"components.PermissionEdit.requestTvDescription": "إعطاء إذن بطلب مسلسلات ليست بجودة ٤ك.",
"components.PermissionEdit.users": "إدارة المستخدمين",
@@ -252,9 +252,9 @@
"components.PermissionEdit.viewissuesDescription": "إعطاء صلاحية برؤية كل المشاكل المبلغ عنها من قبل المستخدمين.",
"components.PermissionEdit.viewrequests": "الإطلاع على الطلبات",
"components.PermissionEdit.viewrequestsDescription": "إعطاء صلاحية بالإطلاع على جميع الطلبات المقدمة من قبل المستخدمين.",
"components.PersonDetails.alsoknownas": "أيضا معروف بـ: {names}",
"components.PersonDetails.alsoknownas": "أيضا يُعرف بإسم: {names}",
"components.PersonDetails.appearsin": "المظهر",
"components.PersonDetails.ascharacter": "بـ {character}",
"components.PersonDetails.ascharacter": "{character} شارك بِدوْر",
"components.PersonDetails.birthdate": "ولد في {birthdate}",
"components.PersonDetails.crewmember": "عضو",
"components.PersonDetails.lifespan": "{birthdate} - {deathdate}",
@@ -273,9 +273,9 @@
"components.RequestBlock.rootfolder": "مجلّد الحفظ",
"components.RequestBlock.seasons": "{seasonCount, plural, one {موسم} other {مواسم}}",
"components.RequestBlock.server": "وجهه السيرفر",
"components.RequestButton.approve4krequests": "الموافقة على {requestCount, plural, one {طلبات ٤ك} other {{requestCount} طلبات ٤ك}}",
"components.RequestButton.approve4krequests": "الموافقة على {requestCount, plural, one {طلب فور كي} other {{requestCount} طلبات فور كي}}",
"components.RequestButton.approverequest": "الموافقة على الطلب",
"components.RequestButton.approverequest4k": "الموافقة على طلب ٤ك",
"components.RequestButton.approverequest4k": "الموافقة على طلب فور كي",
"components.RequestButton.approverequests": "الموافقة على {requestCount, plural, one {طلب} other {{requestCount} طلبات}}",
"components.RequestButton.decline4krequests": "رفض {requestCount, plural, one {طلب الفور كي} other {{requestCount} طلبات الفور كي}}",
"components.RequestButton.declinerequest": "رفض الطلب",
@@ -287,13 +287,13 @@
"components.RequestButton.viewrequest4k": "رؤية تفاصيل طلب الفور كي",
"components.RequestCard.deleterequest": "حذف الطلب",
"components.RequestCard.failedretry": "حدث خطأ ما أثناء محاولة إعادة الطلب.",
"components.RequestCard.mediaerror": "العنوان المتعلق بهذا الطلب لم يعد متوفراً.",
"components.RequestCard.mediaerror": "{mediaType} غير موجود",
"components.RequestCard.seasons": "{seasonCount, plural, one {موسم} other {مواسم}}",
"components.RequestList.RequestItem.cancelRequest": "إلغاء الطلب",
"components.RequestList.RequestItem.deleterequest": "حذف الطلب",
"components.RequestList.RequestItem.editrequest": "تعديل الطلب",
"components.RequestList.RequestItem.failedretry": "حدث خطأ ما أثناء محاولة إعادة الطلب.",
"components.RequestList.RequestItem.mediaerror": "العنوان المتعلق بهذا الطلب لم يعد متوفرًا.",
"components.RequestList.RequestItem.mediaerror": "{mediaType} غير موجود",
"components.RequestList.RequestItem.modified": "تم تعديله",
"components.RequestList.RequestItem.modifieduserdate": "{date} من قبل {user}",
"components.RequestList.RequestItem.requested": "تم طلبه",
@@ -309,20 +309,20 @@
"components.RequestModal.AdvancedRequester.destinationserver": "وجهه السيرفر",
"components.RequestModal.AdvancedRequester.folder": "{path} ({space})'",
"components.RequestModal.AdvancedRequester.languageprofile": "ملف تعريف اللغة",
"components.RequestModal.AdvancedRequester.notagoptions": "لا يوجد علامات\"Tags\".",
"components.RequestModal.AdvancedRequester.notagoptions": "لا يوجد علامات تعريف.",
"components.RequestModal.AdvancedRequester.qualityprofile": "ملف تعريف الجودة",
"components.RequestModal.AdvancedRequester.requestas": "تقديم الطلب بإسم",
"components.RequestModal.AdvancedRequester.rootfolder": "مجلد الحفظ",
"components.RequestModal.QuotaDisplay.allowedRequests": "مسموح لك بطلب <strong>{limit}</strong> {type} كل <strong>{days}</strong> أيام.",
"components.RequestModal.QuotaDisplay.allowedRequestsUser": "هذا المستخدم مسموح له بطلب <strong>{limit}</strong> {type} كل <strong>{days}</strong> أيام.",
"components.RequestModal.QuotaDisplay.movie": "فيلم",
"components.RequestModal.QuotaDisplay.movie": ِيلم",
"components.RequestModal.QuotaDisplay.movielimit": "{limit, plural, one {فيلم} other {أفلام}}",
"components.RequestModal.QuotaDisplay.quotaLinkUser": "يمكنك الإطلاع على ملخص الحد الاقصى لطلبات هذا المستخدم في <ProfileLink>صفحة ملف التعريف</ProfileLink>.",
"components.RequestModal.QuotaDisplay.requestsremaining": "{متبقي, plural, =0 {No} other {<strong>#</strong>}} {type} {متبقي, plural, one {طلب} other {طلبات}} متبقية",
"components.RequestModal.QuotaDisplay.season": "موسم",
"components.RequestModal.QuotaDisplay.seasonlimit": "{limit, plural, one {موسم} other {مواسم}}",
"components.RequestModal.QuotaDisplay.requiredquotaUser": "هذا المستخدم يحتاج أن يملك على الاقل <strong>{seasons}</strong> {seasons, plural, one {طلب موسم} other {طلبات مواسم}} متبقية/متبقي ليتمكن من طلب هذا المسلسل.",
"components.RequestModal.SearchByNameModal.notvdbiddescription": "لم نتمكن من مطابقة طلبك بشكل تلقائي. الرجاء إختيار العنوان المراد من القائمة بالإسفل.",
"components.RequestModal.SearchByNameModal.notvdbiddescription": "لم نتمكن من مطابقة هذا المسلسل بشكل تلقائي. الرجاء مطابقة المسلسل الصحيح من القائمة بالإسفل.",
"components.RequestModal.alreadyrequested": "تم طلبه مُسبقاً",
"components.RequestModal.approve": "الموافقة على الطلب",
"components.RequestModal.autoapproval": "موافقة تلقائية",
@@ -331,12 +331,12 @@
"components.RequestModal.errorediting": "حدث خطأ ما أثناء محاولة تعديل الطلب.",
"components.RequestModal.extras": "إضافات",
"components.RequestModal.numberofepisodes": "# حلقات",
"components.RequestModal.pending4krequest": "",
"components.RequestModal.pending4krequest": "طلب فور كي مُعلّق",
"components.RequestModal.pendingapproval": "طلبك معلق بانتظار الموافقة.",
"components.RequestModal.pendingrequest": "",
"components.RequestModal.pendingrequest": "طلب مُعلّق",
"components.ResetPassword.validationpasswordmatch": "يجب مطابقة كلمة السر مع تأكيدها",
"components.RequestModal.requestmovies": "طلب {count} {count, plural, one {فيلم} other {أفلام}}",
"components.ResetPassword.email": "البريد الإلكتروني",
"components.ResetPassword.email": "عنوان البريد الإلكتروني",
"components.RequestModal.requestseasons4k": "طلب {seasonCount} {seasonCount, plural, one {موسم} other {مواسم}} بجودة فور كي",
"components.ResetPassword.emailresetlink": "رابط إستعادة البريد الإلكتروني",
"components.ResetPassword.passwordreset": "إعادة تعيين كلمة السر",
@@ -392,15 +392,15 @@
"components.Settings.Notifications.NotificationsPushbullet.accessToken": "مفتاح الدخول Token",
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSending": "جاري ارسال التنبيه…",
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestFailed": "فشل إرسال تنبيه تجريبي Pushbullet.",
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSuccess": "تم الإرسال!",
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSuccess": "تم إرسال تنبيه إختبار Pushbullet!",
"components.Settings.Notifications.NotificationsPushbullet.validationTypes": "يجب إختيار نوع تنبيه واحد على الأقل",
"components.Settings.Notifications.NotificationsPushover.toastPushoverTestSuccess": "تم الإرسال!",
"components.Settings.Notifications.NotificationsPushover.toastPushoverTestSuccess": "تم إرسال تنبيه إختبار Pushover!",
"components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "يجب ذكر application Token صحيح",
"components.Settings.Notifications.NotificationsPushbullet.validationAccessTokenRequired": "يجب ذكر Access Token",
"components.Settings.Notifications.NotificationsPushover.userTokenTip": "الثلاثين خانة الخاصة بـ <UsersGroupsLink>بمعرف المستخدم او المجموعة </UsersGroupsLink>",
"components.Settings.Notifications.NotificationsWebhook.resetPayload": "اعادة تعيين",
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSuccess": "تم ارسال تنبيه تجريبي بنجاح!",
"components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "فشل حفظ الاعدادات.",
"components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "فشل حفظ إعدادات تنبيه webkook.",
"components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "تم حفظ الاعدادات!",
"components.Settings.Notifications.agentenabled": "تفعيل الخدمة",
"components.Settings.Notifications.NotificationsPushbullet.accessTokenTip": "عمل مفتاح من <PushbulletSettingsLink>اعدادات الحساب</PushbulletSettingsLink>",
@@ -420,10 +420,10 @@
"components.Settings.Notifications.NotificationsPushover.validationTypes": "يجب عليك اختيار نوع تنبيه واحد على الاقل",
"components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "يجب ذكر مفتاح مستخدم أو مجموعة صحيح",
"components.Settings.Notifications.NotificationsSlack.agentenabled": "تفعيل الخدمة",
"components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "فشل حفظ الاعدادات.",
"components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "فشل حفظ إعدادات تنبيه Slack.",
"components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "تم حفظ الاعدادات بنجاح!",
"components.Settings.Notifications.NotificationsSlack.toastSlackTestFailed": "فشل ارسال تنبيه تجريبي.",
"components.Settings.Notifications.NotificationsSlack.toastSlackTestSending": "جاري ارسال تنبيه تجريبي…",
"components.Settings.Notifications.NotificationsSlack.toastSlackTestSending": "جاري ارسال تنبيه Slack تجريبي…",
"components.Settings.Notifications.NotificationsSlack.toastSlackTestSuccess": "تم ارسال تنبيه تجريبي!",
"components.Settings.Notifications.NotificationsSlack.validationTypes": "يجب عليك اختيار نوع تنبيه",
"components.Settings.Notifications.NotificationsSlack.validationWebhookUrl": "الرجاء تزويد رابط صحيح",
@@ -433,7 +433,7 @@
"components.Settings.Notifications.NotificationsWebPush.httpsRequirement": "لاستقبال تنبيهات بواسطة push , اوفرسيرر يجب ان يكون يعمل من خلال HTTPS.",
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestFailed": "فشل ارسال التنبيه.",
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSuccess": "تم الارسال!",
"components.Settings.Notifications.NotificationsWebPush.webpushsettingsfailed": "فشل حفظ الاعدادات.",
"components.Settings.Notifications.NotificationsWebPush.webpushsettingsfailed": "فشل حفظ إعدادات تنبيه web push.",
"components.Settings.Notifications.NotificationsWebPush.webpushsettingssaved": "تم حفظ الاعدادات بنجاح!",
"components.Settings.Notifications.NotificationsWebhook.agentenabled": "تفعيل الخدمة",
"components.Settings.Notifications.NotificationsWebhook.authheader": "صلاحيات Header",
@@ -441,7 +441,7 @@
"components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "تم اعادة تعيين JSON payload بنجاح!",
"components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "\"Template Variable Help\"",
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestFailed": "فشل ارسال تنبيه تجريبي.",
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSending": "جاري ارسال تنبيه تجريبي…",
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSending": "جاري ارسال تنبيه webhook تجريبي…",
"components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "يجب عليك كتابة رابط صحيح",
"components.Settings.Notifications.NotificationsWebhook.validationTypes": "يجب عليك اختيار نوع تنبيه واحد على الاقل",
"components.Settings.Notifications.NotificationsWebhook.validationWebhookUrl": "يجب عليك كتابة رابط صحيح",
@@ -516,17 +516,17 @@
"components.Settings.RadarrModal.editradarr": "تعديل سيرفر رادار",
"components.Settings.RadarrModal.hostname": "عنوان المضيف أو رقم الأي بي",
"components.Settings.RadarrModal.inCinemas": "متوفر بالسينما",
"components.Settings.RadarrModal.loadingTags": "جاري تحميل العلامات \"Tags\"…",
"components.Settings.RadarrModal.loadingTags": "جاري تحميل علامات التعريف…",
"components.Settings.RadarrModal.loadingprofiles": "جاري تحميل ملفات تعريف الجودة…",
"components.Settings.RadarrModal.notagoptions": "بدون علامات \"Tags\".",
"components.Settings.RadarrModal.notagoptions": "لا يوجد علامات تعريف.",
"components.Settings.RadarrModal.port": "المنفذ",
"components.Settings.RadarrModal.released": "تم إصداره",
"components.Settings.RadarrModal.minimumAvailability": "الحالة الأدنى للتوفر",
"components.Settings.RadarrModal.rootfolder": "مجلد الحفظ",
"components.Settings.RadarrModal.selectMinimumAvailability": "إختار الحالة الأدنى للتوفر",
"components.Settings.RadarrModal.selectQualityProfile": "إختار ملف تعريف الجودة",
"components.Settings.RadarrModal.selecttags": "إختار علامة \"Tags\"",
"components.Settings.RadarrModal.server4k": "سيرفر 4K",
"components.Settings.RadarrModal.selecttags": "إختار علامات التعريف",
"components.Settings.RadarrModal.server4k": "سيرفر جودة الفور كي",
"components.Settings.RadarrModal.servername": "اسم السيرفر",
"components.Settings.RadarrModal.ssl": "استخدم SSL",
"components.Settings.RadarrModal.validationProfileRequired": "يجب اختيار ملف تعريف للجودة",
@@ -536,7 +536,7 @@
"components.Settings.SettingsAbout.Releases.releasedataMissing": "بيانات الاصدار غير متوفرة حاليا.",
"components.Settings.SettingsAbout.Releases.releases": "إصدارات",
"components.Settings.RadarrModal.syncEnabled": "تفعيل البحث",
"components.Settings.RadarrModal.tags": "علامات",
"components.Settings.RadarrModal.tags": "علامات التعريف",
"components.Settings.RadarrModal.testFirstQualityProfiles": "اختبار الاتصال لتحميل ملفات تعريف الجودة",
"components.Settings.RadarrModal.testFirstRootFolders": "اختبار الاتصال لتحميل مجلدات الحفظ",
"components.Settings.RadarrModal.testFirstTags": "اختبار الاتصال لتحميل العلامات",
@@ -582,7 +582,7 @@
"components.Settings.SettingsJobsCache.cacheksize": "حجم المفاتيح",
"components.Settings.SettingsJobsCache.cachemisses": "مفقودات",
"components.Settings.SettingsUsers.newPlexLoginTip": "السماح لمستخدمين بليكس بتسجيل الدخول دون الحاجة لدمجهم في أوفرسيرر",
"components.Settings.SonarrModal.animeTags": "علامات الإنمي",
"components.Settings.SonarrModal.animeTags": "علامات تعريف الإنمي",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "كل {jobScheduleMinutes, plural, one {دقيقة} other {{jobScheduleMinutes} دقائق}}",
"components.Settings.SettingsJobsCache.jobScheduleEditFailed": "حدث خطأ ما اثناء حفظ المهمة.",
"components.Settings.SettingsJobsCache.jobsDescription": "أوفرسيرر يعمل على جدولة بعض مهام الصيانة بشكل دوري ولكن بالإمكان عمل هذه المهام بشكل يدوي من القائمة بالإسفل. عمل اي مهمة بشكل يدوي لن يأثر على جدولتها التلقائية.",
@@ -592,15 +592,15 @@
"components.Settings.SettingsLogs.showall": "إظهار كل السجلات",
"components.Settings.SettingsLogs.logs": "السجلات",
"components.Settings.SonarrModal.baseUrl": "عنوان الارتباط URL BASE",
"components.Settings.SonarrModal.create4ksonarr": "إضافة سيرفر سونار 4K جديد",
"components.Settings.SonarrModal.create4ksonarr": "إضافة سيرفر سونارر لجودة الفور كي",
"components.Settings.SonarrModal.loadinglanguageprofiles": "جاري تحميل ملفات تعريف اللغة…",
"components.Settings.SonarrModal.loadingTags": "جاري تحميل العلامات…",
"components.Settings.SonarrModal.loadingTags": "جاري تحميل علامات التعريف…",
"components.Settings.SonarrModal.loadingprofiles": "جاري تحميل ملفات تعريف الجودة…",
"components.Settings.SettingsJobsCache.command": "أمر",
"components.Settings.SettingsJobsCache.download-sync": "تحميل الدمج",
"components.Settings.SettingsJobsCache.download-sync-reset": "تحميل إعادة تعيين الدمج",
"components.Settings.SettingsJobsCache.editJobSchedule": "تعديل المهمة",
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "معدل التكرار",
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": ّعدل جديد",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "كل {jobScheduleHours, plural, one {ساعة} other {{jobScheduleHours} ساعات}}",
"components.Settings.SettingsJobsCache.flushcache": "محو الملفات المؤقتة",
"components.Settings.SettingsJobsCache.jobScheduleEditSaved": "تم تعديل المهمة بنجاح!",
@@ -650,14 +650,14 @@
"components.Settings.SonarrModal.createsonarr": "إضافة سيرفر سونار جديد",
"components.Settings.SonarrModal.default4kserver": "سيرفر الفور كي الافتراضي",
"components.Settings.SonarrModal.defaultserver": "السيرفر الافتراضي",
"components.Settings.SonarrModal.edit4ksonarr": "تعديل سيرفر سونار 4K",
"components.Settings.SonarrModal.edit4ksonarr": "تعديل سيرفر سونارر لجودة الفور كي",
"components.Settings.SonarrModal.editsonarr": "تعديل سيرفر سونار",
"components.Settings.SonarrModal.enableSearch": "تفعيل البحث التلقائي",
"components.Settings.SonarrModal.externalUrl": "عنوان الارتباط الخارجي",
"components.Settings.SonarrModal.hostname": "إسم المضيف أو رقم الأي بي",
"components.Settings.SonarrModal.languageprofile": "ملف تعريف اللغة",
"components.Settings.SonarrModal.loadingrootfolders": "جاري تحميل مجلدات الحفظ…",
"components.Settings.SonarrModal.notagoptions": "بدون علامات.",
"components.Settings.SonarrModal.notagoptions": "لا يوجد علامات تعريف.",
"components.Settings.SonarrModal.port": "المنفذ",
"components.Settings.SonarrModal.qualityprofile": "ملفات تعريف الجودة",
"components.Settings.SonarrModal.rootfolder": "مجلد الحفظ",
@@ -665,7 +665,7 @@
"components.Settings.SonarrModal.selectLanguageProfile": "إختار ملف تعريف اللغة",
"components.Settings.SonarrModal.selectQualityProfile": "إختار ملف تعريف الجودة",
"components.Settings.SonarrModal.selectRootFolder": "إختار مجلد الحفظ",
"components.Settings.SonarrModal.selecttags": "إختار العلامات",
"components.Settings.SonarrModal.selecttags": "إختار علامات التعريف",
"components.Settings.menuGeneralSettings": "عام",
"components.Settings.manualscanDescription": "عادةً٫ سيتم عمل هذا الفحص مرة واحدة كل 24 ساعة. أوفرسيرر سيفحص المحتوى المضاف مؤخرا في مكتبة بليكس بشكل مكثف. إذا كانت هذه المرة الأولى التي تقوم بها بإعداد بليكس يستحسن أن تقوم بعمل فحص يدوي كامل لمرة واحدة!",
"components.Settings.menuAbout": "حول",
@@ -680,11 +680,11 @@
"components.Settings.webhook": "ويب هوك Webhook",
"components.Settings.webpush": "ويب بوش Web Push",
"components.Setup.configureplex": "إعداد بليكس",
"components.Settings.SonarrModal.server4k": "سيرفر الـ 4K",
"components.Settings.SonarrModal.server4k": "سيرفر جودة الفور كي",
"components.Settings.SonarrModal.servername": "إسم السيرفر",
"components.Settings.SonarrModal.ssl": "إستخدم SSL",
"components.Settings.SonarrModal.syncEnabled": "تفعيل الفحص",
"components.Settings.SonarrModal.tags": "العلامات",
"components.Settings.SonarrModal.tags": "علامات التعريف",
"components.Settings.SonarrModal.testFirstLanguageProfiles": "إختبار الإتصال لتحميل ملفات تعريف اللغة",
"components.Settings.SonarrModal.testFirstQualityProfiles": "إختبار الإتصال لتحميل ملفات تعريف الجودة",
"components.Settings.SonarrModal.testFirstRootFolders": "إختبار الإتصال لتحميل مجلدات الحفظ",
@@ -712,14 +712,14 @@
"components.Settings.default": "الإفتراضي",
"components.Settings.default4k": "فور كي الإفتراضي",
"components.Settings.deleteserverconfirm": "هل أنت متأكد من حذف هذا السيرفر؟",
"components.Settings.email": "البريد الإلكتروني",
"components.Settings.email": "البريد الإلكترونيّ",
"components.Settings.enablessl": "إستخدم SSL",
"components.Settings.externalUrl": "عنوان الإرتباط الخارجي",
"components.Settings.hostname": "عنوان المضيف أو رقم الأي بي",
"components.Settings.is4k": "فور كي",
"components.Settings.librariesRemaining": "المكتبات المتبقية: {count}",
"components.Settings.manualscan": "فحص يدوي للمكتبة",
"components.Settings.mediaTypeMovie": "فيلم",
"components.Settings.mediaTypeMovie": ِيلم",
"components.Settings.mediaTypeSeries": "مسلسل",
"components.Settings.menuJobs": "المهام والملفات المؤقتة",
"components.Settings.menuLogs": "السجلات",
@@ -777,8 +777,8 @@
"components.UserList.users": "المستخدمين",
"components.TvDetails.play4konplex": "تشغيل بجودة فور كي في بليكس",
"components.TvDetails.seasons": "{seasonCount, plural, one {# موسم} other {# مواسم}}",
"components.TvDetails.showtype": "نوع المسلسل",
"components.UserList.accounttype": "النوع",
"components.TvDetails.showtype": "نوُع المسلسل",
"components.UserList.accounttype": "نوع العضويّة",
"components.UserList.admin": "مسؤول",
"components.UserList.displayName": "إسم العرض",
"components.TvDetails.cast": "الطاقم",
@@ -817,21 +817,21 @@
"components.Setup.tip": "تلميحات",
"components.Setup.welcome": "مرحبا بك في أوفرسيرر",
"components.StatusBadge.status": "{status}",
"components.StatusBadge.status4k": "4K {status}",
"components.StatusBadge.status4k": "فور كي {status}",
"components.TvDetails.TvCast.fullseriescast": "طاقم عمل المسلسل",
"components.TvDetails.TvCrew.fullseriescrew": "طاقم فريق المسلسل",
"components.TvDetails.anime": "إنمي",
"components.TvDetails.episodeRuntime": "مدة عرض الحلقة",
"components.TvDetails.episodeRuntimeMinutes": "{runtime} دقيقة",
"components.TvDetails.firstAirDate": "تاريخ العرض الأول",
"components.TvDetails.network": "{networkCount, plural, one {شبكة بث} other {شبكات بث}}",
"components.TvDetails.network": "{networkCount, plural, one {شبكة البث} other {شبكات البث}}",
"components.TvDetails.nextAirDate": "تاريخ العرض التالي",
"components.TvDetails.originallanguage": "اللغة الأصلية",
"components.TvDetails.originaltitle": "العنوان الأصلي",
"components.TvDetails.overview": "نظرة عامة",
"components.TvDetails.overviewunavailable": "النظرة العامة غير متاحة.",
"components.TvDetails.playonplex": "تشغيل في بليكس",
"components.TvDetails.productioncountries": "الإنتاج {countryCount, plural, one {الدولة} other {الدول}}",
"components.TvDetails.productioncountries": "إنتاج {countryCount, plural, one {الدولة} other {الدول}}",
"components.TvDetails.recommendations": "التوصيات",
"components.TvDetails.similar": "مسلسلات مشابهه",
"components.TvDetails.viewfullcrew": "رؤية طاقم العمل",
@@ -840,7 +840,7 @@
"components.UserList.autogeneratepasswordTip": "أرسل كلمة السر المنشئة الى إيميل المستخدم",
"components.UserList.bulkedit": "تعديل شامل ( للمحدد )",
"components.UserList.create": "إنشاء",
"components.UserList.created": "مُنضم",
"components.UserList.created": "تاريخ الإنضمامْ",
"components.UserList.createlocaluser": "إنشاء مستخدم محلي",
"components.UserList.creating": "جاري الإنشاء…",
"components.UserList.deleteconfirm": "هل أنت متأكد من حذف هذا المستخدم؟ كل الطلبات التي تم إنشاءها من خلاله سيتم حذفها بشكل نهائي.",
@@ -854,18 +854,18 @@
"components.UserList.password": "كلمة السر",
"components.UserList.passwordinfodescription": "ضبط عنوان إرتباط التطبيق وتفعيل تنبيهات الإيميل للسماح بإنشاء كلمات سر تلقائية.",
"components.UserList.plexuser": "مستخدم بليكس",
"components.UserList.role": "الدور",
"components.UserList.role": "صلاحيّات الحساب",
"components.UserList.sortCreated": "تاريخ الإنضمام",
"components.UserList.sortDisplayName": "إسم العرض",
"components.UserList.sortRequests": "عدد الطلبات",
"components.UserList.totalrequests": "الطلبات",
"components.UserList.user": "المستخدم",
"components.UserList.user": ُستخدم/عضو",
"components.UserList.usercreatedfailed": "حدث خطأ ما أثناء إنشاء المستخدم.",
"components.UserList.usercreatedfailedexisting": "عنوان البريد الإلكتروني المزوّد مستعمل من قبل مستخدم أخر.",
"components.UserList.usercreatedsuccess": "تم إنشاء المستخدم بنجاح!",
"components.UserList.userdeleteerror": "حدث خطأ ما أثناء حذف المستخدم.",
"components.UserList.userfail": "حدث خطأ ما أثناء حفظ صلاحيات المستخدم.",
"components.UserList.userlist": "قائمة المستخدم",
"components.UserList.userlist": "قائمة المستخدمين",
"components.UserList.userssaved": "تم حفظ صلاحيات المستخدم بنجاح!",
"components.UserList.validationEmail": "يجب عليك ذكر عنوان بريد إلكتروني صحيح",
"components.UserList.validationpasswordminchars": "كلمة السر قصيرة جدا؛ الحد الأدنى 8 خانات",
@@ -886,7 +886,7 @@
"components.UserProfile.UserSettings.UserGeneralSettings.owner": "المالك",
"components.UserProfile.UserSettings.UserGeneralSettings.plexuser": "مستخدم بليكس",
"components.UserProfile.UserSettings.UserGeneralSettings.region": "المنطقة لـ إكتشف محتوى جديد",
"components.UserProfile.UserSettings.UserGeneralSettings.role": "الدور",
"components.UserProfile.UserSettings.UserGeneralSettings.role": "صلاحيّات الحساب",
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "الحد الأقصى لطلبات المسلسلات",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "حدث خطأ ما أثناء حفظ الإعدادات.",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "تم حفظ الإعدادات بنجاح!",
@@ -894,7 +894,7 @@
"components.UserProfile.UserSettings.UserNotificationSettings.discordId": "رقم عضوية المستخدم",
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "ة <FindDiscordIdLink>رقم متعدد الخانات</FindDiscordIdLink> مرتبط بحساب المستخدم",
"components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "فشل حفظ إعدادات تنبيه الديسكورد.",
"components.UserProfile.UserSettings.UserNotificationSettings.email": "البريد الإلكتروني",
"components.UserProfile.UserSettings.UserNotificationSettings.email": "البريد الإلكترونيّ",
"components.UserProfile.UserSettings.UserNotificationSettings.emailsettingsfailed": "فشل حفظ إعدادات تنبيه الإيميل.",
"components.UserProfile.UserSettings.UserNotificationSettings.emailsettingssaved": "تم حفظ إعدادات تنبيه الإيميل بنجاح!",
"components.UserProfile.UserSettings.UserNotificationSettings.notifications": "التنبيهات",
@@ -966,7 +966,7 @@
"i18n.import": "إستيراد/جلب",
"i18n.importing": "جاري الإستيراد…",
"i18n.loading": "جاري التحميل…",
"i18n.movie": "فيلم",
"i18n.movie": "فيلِم",
"i18n.movies": "أفلام",
"i18n.next": "التالي",
"i18n.noresults": "لا يوجد نتائج.",
@@ -975,7 +975,7 @@
"i18n.partiallyavailable": "متوفر بشكل جزئي",
"i18n.pending": "معلّق",
"i18n.previous": "السابق",
"i18n.processing": "تنفيذ",
"i18n.processing": "قيد التنفيذ",
"i18n.request": "طلب",
"i18n.request4k": "طلب بجودة فور كي",
"i18n.requested": "تم طلبه",
@@ -990,7 +990,7 @@
"i18n.status": "الحالة",
"i18n.test": "إختبار",
"i18n.testing": "جاري الإختبار…",
"i18n.tvshow": "مسلسلات",
"i18n.tvshow": "مسلسلْ",
"i18n.tvshows": "مسلسلات",
"i18n.unavailable": "غير متاح",
"i18n.usersettings": "إعدادات المستخدم",
@@ -1001,5 +1001,236 @@
"pages.pagenotfound": "الصفحة غير متاحة",
"pages.returnHome": "عودة للقائمة الرئيسية",
"pages.serviceunavailable": "الخدمة غير متاحة",
"components.RequestBlock.languageprofile": "لغة ملف التعريف"
"components.RequestBlock.languageprofile": "لغة ملف التعريف",
"components.Discover.FilterSlideover.tmdbuservotecount": "عدد المقيّمين",
"components.Discover.FilterSlideover.voteCount": "عدد المقيمين بين {minValue} و {maxValue}",
"components.Discover.PlexWatchlistSlider.plexwatchlist": "قائمتك الخاصة برغبات بليكس",
"components.Discover.resetfailed": "حدث خطأ ما خلال إعادة ضبط إعدادت (إكتشف) المخصّصة.",
"components.Discover.resetwarning": "إعادة ضبط شرائط المحتوى لوضعها الإفتراضي. سيؤدي هذا أيضًا إلى حذف أي أشرطة محتوى مخصصة!",
"components.Discover.stopediting": "إلغاء التعديل",
"components.Discover.tmdbmoviekeyword": "كلمات بحث رئيسية للأفلام من (TMDB)",
"components.Discover.studios": "إستديوهات الإنتاج",
"components.Discover.updatesuccess": "تم تحديث إعدادات التخصيص لصفحة إكتشف.",
"components.Layout.UserDropdown.MiniQuotaDisplay.movierequests": "طلبات الأفلام",
"components.Layout.UserDropdown.MiniQuotaDisplay.seriesrequests": "طلبات المسلسلات",
"components.MovieDetails.digitalrelease": "الإصدار الرقمي",
"components.MovieDetails.reportissue": "قم بالإبلاغ عن مشكلة",
"components.MovieDetails.rtaudiencescore": "تقييم الجمهور من موقع (Rotten Tomatoes)",
"components.MovieDetails.rtcriticsscore": "تقييم النقاد من موقع Rotten Tomatoes",
"components.RequestCard.declinerequest": "رفض الطلب",
"components.RequestList.RequestItem.unknowntitle": "عنوان غير معروف",
"components.RequestModal.requestseriestitle": "طلب مسلسل",
"components.Selector.searchStudios": "بحث بالإستديوهات…",
"components.Selector.showmore": "إظهار المزيد",
"components.Selector.showless": "رؤية تفاصيل أقل",
"components.Settings.SettingsJobsCache.availability-sync": "دمج توفر المحتويات",
"components.Settings.SettingsMain.applicationTitle": "إسم التطبيق",
"components.StatusBadge.managemedia": "إدارة {mediaType}",
"i18n.restartRequired": "يتطلب إعادة التشغيل",
"components.TvDetails.rtcriticsscore": "تقييم النقاد من موقع Rotten Tomatoes",
"components.Settings.RadarrModal.tagRequests": "رموز تعريف الطلبات",
"components.Settings.RadarrModal.tagRequestsInfo": "بشكل تلقائي إضافة رمز تعريف يحتوي على معرّف المستخدم وإسم العرض الخاص بمقدّم الطلب",
"components.Settings.SettingsMain.apikey": "مفتاح API",
"components.Settings.SettingsMain.validationApplicationTitle": "يجب تقديم عنوان للتطبيق",
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "الرابط يجب لا أن ينتهي بعلامة سلاش",
"components.Settings.SonarrModal.tagRequests": "علامات تعريف الطلبات",
"components.Settings.SonarrModal.tagRequestsInfo": "بشكل تلقائي إضافة علامة تعريف مع معرّف المستخدم وإسم العرض الخاص بمقدّم الطلب",
"components.Settings.restartrequiredTooltip": "يجب إعادة تشغيل أوفرسيرر لتفعيل هذه التغييرات",
"components.TitleCard.tvdbid": "معّرف موقع TVDB",
"components.TvDetails.manageseries": "إدارة المسلسل",
"components.TvDetails.tmdbuserscore": "تقييم المستخدمين من موقع TMDB",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseries": "تقديم تلقائي لطلبات المسلسلات",
"i18n.collection": "تجميعة",
"components.RequestBlock.approve": "الموافقة على الطلب",
"components.RequestBlock.requestedby": "تم الطلب من قبل",
"components.Settings.SettingsMain.csrfProtection": "تفعيل حماية CSRF",
"components.Settings.SettingsMain.generalsettingsDescription": "ضبط الإعدادات العامة والإفتراضية بأوفرسيرر.",
"components.StatusChecker.appUpdated": "{applicationTitle} تم التحديث",
"components.TitleCard.cleardata": "محو البيانات",
"components.TitleCard.mediaerror": "{mediaType} غير موجود",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "قائمة الرغبات (watchlist) الخاصة ببليكس",
"components.RequestList.RequestItem.tvdbid": "المعرّف الخاص بموقع TVDB",
"components.Discover.DiscoverWatchlist.watchlist": "قائمة رغبات بليكس",
"components.Settings.experimentalTooltip": "تفعيل هذا الخيار قد يؤدي الى نتائج غير متوقعة بأداء التطبيق",
"components.Discover.tmdbtvstreamingservices": "شبكات بث المسلسلات من TMDB",
"components.Discover.tmdbmoviestreamingservices": "شبكات بث الأفلام من (TMDB)",
"components.Layout.UserDropdown.requests": "الطلبات",
"components.MovieDetails.physicalrelease": "الإصدار الفعلي لأقراص البلوري",
"components.NotificationTypeSelector.mediaautorequestedDescription": "إستقبال تنبيهات عندما يتم تقديم طلبات بشكل تلقائي من رغبات بليكس.",
"components.PermissionEdit.autorequestSeries": "طلبات تلقائية للمسلسلات",
"components.PermissionEdit.viewrecentDescription": "إعطاء الإذن برؤية قائمة المُضاف مؤخراً.",
"components.PermissionEdit.viewwatchlists": "رؤية قائمة رغبات بليكس",
"components.RequestBlock.lastmodifiedby": "أخر تعديل بواسطة",
"components.RequestBlock.requestdate": "تاريخ الطلب",
"components.RequestCard.approverequest": "الموافقة على الطلب",
"components.RequestCard.cancelrequest": "إلغاء الطلب",
"components.RequestCard.editrequest": "تعديل الطلب",
"components.RequestCard.tvdbid": "المعرّف الخاص بموقع TVDB",
"components.Selector.searchKeywords": "بحث بالكلمات الرئيسية أو الدّالة…",
"components.Selector.searchGenres": "إختيار نوع المحتوى…",
"components.Selector.starttyping": "قم بالكتابة لبدء البحث.",
"components.Settings.SettingsJobsCache.editJobScheduleCurrent": "المُعدّل الحالي",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "كل {jobScheduleSeconds, plural, one {ثانية} other {{jobScheduleSeconds} ثواني}}",
"components.Settings.SettingsJobsCache.image-cache-cleanup": "تنظيف تخزين الصور المؤقت",
"components.Settings.SettingsJobsCache.imagecache": "تخزين الصور المؤقت",
"components.Settings.SettingsJobsCache.imagecachecount": "الصور والملتقطات المُخزّنة",
"components.Settings.SettingsJobsCache.imagecachesize": "المجموع الكُلي لحجم الملتقطات",
"components.Settings.SettingsJobsCache.plex-watchlist-sync": "دمج رغبات بليكس",
"components.Settings.SettingsLogs.viewdetails": "رؤية التفاصيل",
"components.Settings.SettingsMain.applicationurl": "رابط التطبيق",
"components.Settings.SettingsMain.originallanguage": "اللغة الخاصة بصفحة إكتشف",
"components.Settings.deleteServer": "حذف {serverType} سيرفر",
"components.StatusBadge.openinarr": "فتح في {arr}",
"components.StatusChecker.restartRequired": "مطلوب إعادة تشغيل السيرفر",
"components.StatusBadge.playonplex": "تشغيل في بليكس",
"components.StatusChecker.restartRequiredDescription": "الرجاء إعادة تشغيل السيرفر لتطبيق الإعدادات المُحدثة.",
"components.TitleCard.tmdbid": "معرّف موقع TMDB",
"components.TvDetails.episodeCount": "{episodeCount, plural, one {# حلقة} other {# حلقات}}",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmovies": "تقديم طلبات تلقائي للأفلام",
"components.UserProfile.emptywatchlist": "المحتوى المضاف إلى <PlexWatchlistSupportLink>رغبات بليكس </PlexWatchlistSupportLink> سيظهر هنا.",
"components.AirDateBadge.airsrelative": "تُعرضْ {relativeTime}",
"components.PermissionEdit.autorequestMovies": "طلبات تلقائية للأفلام",
"components.StatusChecker.reloadApp": "إعادة تحميل {applicationTitle}",
"components.TvDetails.seasonstitle": "مواسم",
"components.TvDetails.status4k": "فور كي {status}",
"components.Discover.createnewslider": "إنشاء شريط محتوى جديد",
"components.Discover.customizediscover": "تخصيص صفحة (إكتشف)",
"components.Discover.moviegenres": "أنواع الأفلام",
"components.Discover.networks": "شبكات البث",
"components.Discover.resettodefault": "إعادة ضبط للوضع الإفتراضي",
"components.Discover.resetsuccess": "تم إعادة ضبط إعدادات إكتشف المخصصة لوضعها الإفتراضي.",
"components.Discover.tmdbstudio": "إستديوهات من TMDB",
"components.Discover.tmdbnetwork": "شبكات البث من (TMDB)",
"components.Discover.tmdbsearch": "بث في TMDB",
"components.Discover.tvgenres": "أنواع المسلسلات",
"components.Discover.updatefailed": "حدث خطأ ما أثناء تحديث الإعدادات المخصصة لصفحة إكتشف.",
"components.DownloadBlock.formattedTitle": "{title}: موسم {seasonNumber} حلقة {episodeNumber}",
"components.TvDetails.reportissue": "الإبلاغ عن مشكلةْ",
"components.TvDetails.rtaudiencescore": "تقييم الجمهور من موقع Rotten Tomatoes",
"components.Settings.SettingsMain.trustProxyTip": "السماح لأوفرسيرر بتسجيل عناوين IP خلف بروكسي",
"components.Discover.DiscoverMovies.discovermovies": "أفلام",
"components.Discover.DiscoverMovies.sortPopularityAsc": "الشعبية تصاعديا",
"components.Discover.DiscoverMovies.sortPopularityDesc": "الشعبية تنازلياً",
"components.Discover.DiscoverMovies.sortTitleAsc": "العناوين بتصاعد أبجدي (بحسب الأحرف الإنجليزية)",
"components.Discover.DiscoverMovies.sortTitleDesc": "العناوين بتنازل أبجدي (بحسب الأحرف الإنجليزية)",
"components.Discover.DiscoverMovies.sortTmdbRatingAsc": "تقييم TMDB بترتيب تصاعدي",
"components.Discover.DiscoverSliderEdit.deletesuccess": "تم حذف شريط المحتوى.",
"components.Discover.DiscoverMovies.sortTmdbRatingDesc": "تقييم TMDB بترتيب تنازلي",
"components.Discover.DiscoverSliderEdit.deletefail": "فشل حذف شريط المحتوى.",
"components.Discover.DiscoverSliderEdit.enable": "التبديل للرؤية",
"components.Discover.DiscoverSliderEdit.remove": "حَذف",
"components.Discover.DiscoverTv.discovertv": "مُسلسل",
"components.Discover.DiscoverTv.sortFirstAirDateAsc": "تاريخ البث بترتيب تصاعدي",
"components.Discover.DiscoverTv.activefilters": "{count, plural, one {# الفلتر المفعل} other {# الفلاتر المفعلة}}",
"components.Discover.DiscoverTv.sortPopularityAsc": "الشعبية بترتيب تصاعدي",
"components.Discover.DiscoverTv.sortPopularityDesc": "الشعبية بترتيب تنازلي",
"components.Discover.DiscoverTvKeyword.keywordSeries": "{keywordTitle} مسلسل",
"components.Discover.FilterSlideover.activefilters": "{count, plural, one {# الفلتر المفعل} other {# الفلاتر المفعلة}}",
"components.Discover.FilterSlideover.clearfilters": "محو الفلاتر المفعلة",
"components.Discover.FilterSlideover.filters": "فلاتر",
"components.Discover.FilterSlideover.from": "من",
"components.Discover.FilterSlideover.originalLanguage": "اللغة الأصلية",
"components.Discover.FilterSlideover.runtime": "مُدّة العرض",
"components.Discover.FilterSlideover.tmdbuserscore": "تقييم المستخدمين من (TMDB)",
"components.Discover.FilterSlideover.to": "إلى",
"components.Discover.FilterSlideover.studio": "إستديوهات",
"components.Discover.RecentlyAddedSlider.recentlyAdded": "أُضيفَ مؤخراً",
"components.Discover.tmdbmoviegenre": "نوع الأفلام من (TMDB)",
"components.Discover.tmdbtvgenre": "أنواع المسلسلات من TMDB",
"components.Discover.plexwatchlist": "قائمتك الخاصة برغبات بليكس",
"components.MovieDetails.theatricalrelease": "الإصدار السينمائي (العرض بالسينما)",
"components.MovieDetails.tmdbuserscore": "تقييم مستخدمين TMDB",
"components.NotificationTypeSelector.mediaautorequested": "تم تقديم الطلب بشكل تلقائي",
"components.PermissionEdit.autorequest": "طلب تلقائي",
"components.PermissionEdit.autorequestSeriesDescription": "إعطاء الإذن بتقديم طلبات تلقائية للمسلسلات بغير جودة الفور كي من خلال رغبات بليكس.",
"components.PermissionEdit.viewrecent": "رؤية ماتم إضافته مؤخراً",
"components.PermissionEdit.viewwatchlistsDescription": "إعطاء الإذن برؤية رغبات بليكس الخاصة بكل المستخدمين الأخرين.",
"components.RequestBlock.decline": "رفض الطلب",
"components.RequestBlock.edit": "تعديل الطلب",
"components.RequestCard.tmdbid": "المعرّف الخاص بموقع TMDB",
"components.RequestList.RequestItem.tmdbid": "المعرّف الخاص بموقع TMDB",
"components.RequestModal.requestseries4ktitle": "طلب مسلسل بجودة فور كي",
"components.Settings.SettingsMain.cacheImages": "تفعيل تخزين الملتقطات والصور",
"components.Settings.SettingsMain.csrfProtectionHoverTip": "لا تقم بتفعيل هذا الخيار إلا إذا كنت تعيّ ماتقوم به!",
"components.Settings.SettingsMain.csrfProtectionTip": "إعداد خارجي بمفتاح API بصلاحية القراءة فقط (هذا الخيار يتطلب إتصال مُشفر HTTP)",
"components.Settings.SettingsMain.generalsettings": "إعدادات عامة",
"components.Settings.SettingsMain.locale": "لغة العرض",
"components.Settings.SettingsMain.region": "المنطقة/الإقليم الخاص بمحتوى إكتشف",
"components.Settings.SettingsMain.regionTip": "فلترة المحتوى بحسب توفّره بالإقليم/المنطقة",
"components.Settings.SettingsMain.toastSettingsSuccess": "تم حفظ الإعدادات!",
"components.Settings.SettingsMain.trustProxy": "تفعيل دعم البروكسي",
"components.StatusChecker.appUpdatedDescription": "الرجاء النقر على الزر بالإسفل لإعادة تحميل الصفحة.",
"components.AirDateBadge.airedrelative": "عُرضت {relativeTime}",
"components.Discover.DiscoverMovies.activefilters": "{count, plural, one {# الفلتر المُفعّل } other {# الفلاتر المفعلة}}",
"components.Discover.DiscoverMovies.sortReleaseDateAsc": "تاريخ الإصدار تصاعدياً",
"components.Discover.DiscoverMovies.sortReleaseDateDesc": "تاريخ الإصدار تنازلياً",
"components.Discover.DiscoverTv.sortFirstAirDateDesc": "تاريخ البث بترتيب تنازلي",
"components.Discover.DiscoverTv.sortTitleAsc": "العنوانين بترتيب ابجدي تصاعدي",
"components.Discover.DiscoverTv.sortTitleDesc": "العناوين بترتيب أبجدي تنازلي",
"components.Discover.DiscoverTv.sortTmdbRatingAsc": "تقييم TMDB بترتيب تصاعدي",
"components.Discover.DiscoverTv.sortTmdbRatingDesc": "تقييم TMDB بترتيب تنازلي",
"components.Discover.FilterSlideover.firstAirDate": "تاريخ البث",
"components.Discover.FilterSlideover.keywords": "كلمات رئيسية أو دالّة",
"components.Discover.FilterSlideover.ratingText": "التقييم بين {minValue} و {maxValue}",
"components.Discover.FilterSlideover.releaseDate": "تاريخ الإصدار",
"components.MovieDetails.managemovie": "إدارة الفيلم",
"components.Layout.Sidebar.browsemovies": "أفلام",
"components.Layout.Sidebar.browsetv": "مسلسلاتْ",
"components.PermissionEdit.autorequestDescription": "إعطاء الإذن بتقديم طلبات تلقائية للمحتويات (بغير جودة الفور كي) من خلال رغبات بليكس.",
"components.PermissionEdit.autorequestMoviesDescription": "إعطاء الإذن بتقديم طلبات أفلام تلقائية بغير جودة الفور كي من خلال رغبات بليكس.",
"components.RequestCard.unknowntitle": "عنوان غير معروف",
"components.RequestModal.SearchByNameModal.nomatches": "لم يتم العثور على عنوان مطابق لهذا المسلسل.",
"components.RequestModal.requestcollection4ktitle": "طلب تجميعة بجودة فور كي (تجمعية هي الأفلام بأجزاء متعددة أو مرتبطة)",
"components.RequestModal.requestmovie4ktitle": "طلب فيلم بجودة فور كي",
"components.RequestModal.requestmovietitle": "طلب فيلم",
"components.Selector.nooptions": "لا يوجد نتائج.",
"components.RequestModal.requestcollectiontitle": "طلب تجميعة (التجميعة هي حزمة أفلام مرتبطة ببعضها)",
"components.Settings.SettingsJobsCache.imagecacheDescription": "عند التفعيل بالإعدادات، أوفرسيرر سيقوم بحفظ الملتقطات والصور. يتم تخزين هذه الملتقطات في مجلد الضبط الخاص بك. تستطيع إيجاد هذه الملفات في <code>{appDataPath}/cache/images</code>.",
"components.Settings.SettingsMain.cacheImagesTip": "الملتقطات من المصادر الخارجية (تتطلب حجم هائل متوفر في مساحة التخزين الخاصة بجهازك)",
"components.Settings.SettingsMain.general": "عام",
"components.Settings.SettingsMain.hideAvailable": "إخفاء المحتوى المتوفّر",
"components.Settings.SettingsMain.originallanguageTip": "فلترة المحتوى باللغة الأصلية",
"components.Settings.SettingsMain.partialRequestsEnabled": "السماح بالطلبات الجزئية للمسلسلات (بمعنى مسلسل لم ينتهي عرض الموسم بالكامل)",
"components.Settings.SettingsMain.toastApiKeyFailure": "حدث خطأ ما أثناء إنشاء مفتاح API جديد.",
"components.Settings.SettingsMain.toastSettingsFailure": "حدث خطأ ما أثناء حفظ الإعدادات.",
"components.Settings.SettingsMain.toastApiKeySuccess": "تم إنشاء مفتاح API جديد بنجاح!",
"components.Settings.SettingsMain.validationApplicationUrl": "يجب تقديم رابط صحيح",
"components.Settings.advancedTooltip": "ضبط هذا الإعداد بشكل غير صحيح قد يؤدي الى عدم عمل بعض الخصائص",
"components.StatusBadge.seasonepisodenumber": "م{seasonNumber}ح{episodeNumber}",
"components.TvDetails.Season.somethingwentwrong": "حدث خطأ ما أثناء محاولة جلب بيانات الموسم.",
"components.TvDetails.Season.noepisodes": "قائمة الحلقات غير متوفرة.",
"components.TvDetails.seasonnumber": "موسم {seasonNumber}",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmoviestip": "بشكل تلقائي تقديم طلبات الأفلام <PlexWatchlistSupportLink>من رغبات بليكس </PlexWatchlistSupportLink>",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseriestip": "تقديم الطلبات بشكل تلقائي للمسلسلات<PlexWatchlistSupportLink>من رغبات بليكس </PlexWatchlistSupportLink>",
"components.Discover.FilterSlideover.genres": "أنواع المحتوى",
"components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} مدة العرض بالدقائق",
"components.RequestBlock.delete": "حذف الطلب",
"components.UserProfile.plexwatchlist": "رغبات بليكس",
"components.Discover.emptywatchlist": "المحتوى المُضاف إلى<PlexWatchlistSupportLink>رغبات بليكس </PlexWatchlistSupportLink> سيظهر هنا.",
"components.Discover.tmdbtvkeyword": "كلمات بحث رئيسية عن المسلسلات من TMDB",
"components.Discover.FilterSlideover.streamingservices": "شبكات البثّ",
"components.Discover.PlexWatchlistSlider.emptywatchlist": "المحتوى المُضاف إلى <PlexWatchlistSupportLink>رغبات بليكس </PlexWatchlistSupportLink> سيظهر هنا.",
"components.Discover.CreateSlider.addcustomslider": "إنشاء شريط محتوى مخصّص",
"components.Discover.CreateSlider.addSlider": "إضافة شريط محتوى",
"components.Discover.CreateSlider.addfail": "فشل إنشاء شريط محتوى جديد.",
"components.Discover.CreateSlider.editSlider": "تعديل شريط المحتوى",
"components.Discover.CreateSlider.editfail": "فشل تعديل شريط المحتوى.",
"components.Discover.CreateSlider.editsuccess": "تم تعديل الشريط وتم حفظ إعدادات التخصيص لصفحة (إكتشف).",
"components.Discover.CreateSlider.addsuccess": "تم إنشاء شريط جديد و تم حفظ إعدادات التخصيص لصفحة (إكتشف).",
"components.Discover.CreateSlider.needresults": "يجب أن يكون لديك نتيجة واحدة على الأقل.",
"components.Discover.CreateSlider.nooptions": "لا يوجد نتائج.",
"components.Discover.CreateSlider.providetmdbgenreid": "قم بتوفير معرّف نوع المحتوى من (TMDB)",
"components.Discover.CreateSlider.providetmdbnetwork": "قم بتوفير معرّف شبكة البث من (TMDB)",
"components.Discover.CreateSlider.providetmdbsearch": "قم بتوفير بحث إستعلام",
"components.Discover.CreateSlider.providetmdbstudio": "قم بتوفير معرّف الإستديو من (TMDB)",
"components.Discover.CreateSlider.searchGenres": "بحث بنوع المحتوى…",
"components.Discover.CreateSlider.searchKeywords": "بحث بكلمات رئيسية…",
"components.Discover.CreateSlider.searchStudios": "بحث عن الإستديوهات…",
"components.Discover.CreateSlider.slidernameplaceholder": "إسم شريط المحتوى",
"components.Discover.CreateSlider.starttyping": "قم بالكتابة لبدء البحث.",
"components.Discover.CreateSlider.validationDatarequired": "يجب تقديم بيانات.",
"components.Discover.CreateSlider.validationTitlerequired": "يجب تقديم عنوان.",
"components.Discover.DiscoverMovieKeyword.keywordMovies": "{keywordTitle} الأفلام",
"components.Discover.CreateSlider.providetmdbkeywordid": "قم بتوفير معرّف كلمات بحث رئيسية من (TMDB)",
"components.MovieDetails.imdbuserscore": "تقييم مستخدمين IMDB"
}

View File

@@ -19,7 +19,7 @@
"components.RequestModal.numberofepisodes": "# d'episodis",
"components.RequestModal.extras": "Extres",
"components.RequestModal.errorediting": "S'ha produït un error en editar la sol·licitud.",
"components.RequestModal.cancel": "Cancel·la la petició",
"components.RequestModal.cancel": "Cancel·la la sol·licitud",
"components.RequestModal.autoapproval": "Aprovació automàtica",
"components.RequestModal.alreadyrequested": "Ja s'ha sol·licitat",
"components.RequestModal.SearchByNameModal.notvdbiddescription": "No s'ha pogut trobar coincidència d'aquesta serie. Seleccioneu l'emparellament correcte de la llista següent.",
@@ -108,22 +108,22 @@
"components.PermissionEdit.autoapprove4kDescription": "Concedeix laprovació automàtica a totes les sol·licituds de contingut 4K.",
"components.PermissionEdit.autoapprove4k": "Aprovació automàtica 4K",
"components.PermissionEdit.autoapprove": "Aprovació automàtica",
"components.NotificationTypeSelector.mediadeclined": "Continguts rebutjats",
"components.NotificationTypeSelector.mediadeclined": "Sol·licitud rebutjada",
"components.NotificationTypeSelector.mediarequested": "Sol·licitud pendent d'aprovació",
"components.NotificationTypeSelector.mediaAutoApprovedDescription": "Envieu notificacions quan els usuaris envien sol·licituds de suports nous que saprovin automàticament.",
"components.PermissionEdit.advancedrequestDescription": "Dona permís per modificar opcions avançades en les sol·licituds.",
"components.NotificationTypeSelector.mediaAutoApprovedDescription": "Envieu notificacions quan els usuaris envien sol·licituds de contingut nous que saprovin automàticament.",
"components.PermissionEdit.advancedrequestDescription": "Dona permís per modificar opcions avançades en les sol·licituds de contingut.",
"components.PermissionEdit.advancedrequest": "Sol·licituds avançades",
"components.PermissionEdit.adminDescription": "Accés complet d'administrador. Ignora totes les altres comprovacions de permisos.",
"components.PermissionEdit.admin": "Administrador",
"components.NotificationTypeSelector.mediarequestedDescription": "Envia notificacions quan es demana contingut que requereix aprovació.",
"components.NotificationTypeSelector.mediafailedDescription": "Envia notificacions quan el mitjà sol·licitat no safegeix a Radarr o Sonarr.",
"components.NotificationTypeSelector.mediarequestedDescription": "Envia notificacions quan es sol·licita contingut que requereix aprovació.",
"components.NotificationTypeSelector.mediafailedDescription": "Envia notificacions quan el contingut sol·licitat no safegeix a Radarr o Sonarr.",
"components.NotificationTypeSelector.mediafailed": "S'ha produït un error en el processament de la sol·licitud",
"components.NotificationTypeSelector.mediadeclinedDescription": "Envia notificacions quan es rebutja una sol·licitud.",
"components.NotificationTypeSelector.mediaavailableDescription": "Envia notificacions quan el contingut sol·licitat estigui disponible.",
"components.NotificationTypeSelector.mediaavailable": "Continguts disponibles",
"components.NotificationTypeSelector.mediaapprovedDescription": "Envia notificacions quan el contingut sol·licitat saprova manualment.",
"components.NotificationTypeSelector.mediaapproved": "Continguts aprovats",
"components.NotificationTypeSelector.mediaAutoApproved": "Continguts aprovats automàticament",
"components.NotificationTypeSelector.mediaavailableDescription": "Envia notificacions quan la sol·licitud estigui disponible.",
"components.NotificationTypeSelector.mediaavailable": "Sol·licitud disponible",
"components.NotificationTypeSelector.mediaapprovedDescription": "Envia notificacions quan la sol·licitud saprova manualment.",
"components.NotificationTypeSelector.mediaapproved": "Sol·licitud aprovada",
"components.NotificationTypeSelector.mediaAutoApproved": "Sol·licituds aprovades automàticament",
"components.MovieDetails.watchtrailer": "Mireu el tràiler",
"components.MovieDetails.viewfullcrew": "Veure equip complet",
"components.MovieDetails.similar": "Títols similars",
@@ -191,7 +191,7 @@
"components.Discover.TvGenreSlider.tvgenres": "Gèneres de Sèries",
"components.Discover.TvGenreList.seriesgenres": "Gèneres de Sèries",
"components.Discover.StudioSlider.studios": "Estudis",
"components.Discover.NetworkSlider.networks": "Plataforma",
"components.Discover.NetworkSlider.networks": "Emissors",
"components.Discover.MovieGenreSlider.moviegenres": "Gèneres de Pel·lícules",
"components.Discover.MovieGenreList.moviegenres": "Gèneres de Pel·lícules",
"components.Discover.DiscoverTvLanguage.languageSeries": "Sèries en {language}",
@@ -368,11 +368,11 @@
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "S'ha produït un error en desar la configuració.",
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Límit de sol·licituds de sèries",
"components.UserProfile.UserSettings.UserGeneralSettings.regionTip": "Filtra el contingut per disponibilitat regional",
"components.UserProfile.UserSettings.UserGeneralSettings.region": "Regió per a la secció \"Descobriu\"",
"components.UserProfile.UserSettings.UserGeneralSettings.region": "Regió de Descobriu",
"components.UserProfile.UserSettings.UserGeneralSettings.plexuser": "Usuari de Plex",
"components.UserProfile.UserSettings.UserGeneralSettings.owner": "Propietari",
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguageTip": "Filtra el contingut per l'idioma original",
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguage": "Idioma per a la secció \"Descobriu\"",
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguage": "Idioma per a la secció «Descobriu»",
"components.UserProfile.UserSettings.UserGeneralSettings.movierequestlimit": "Límit de sol·licituds de pel·lícules",
"components.UserProfile.UserSettings.UserGeneralSettings.localuser": "Usuari local",
"components.UserProfile.UserSettings.UserGeneralSettings.generalsettings": "Configuració general",
@@ -397,7 +397,7 @@
"components.TvDetails.originaltitle": "Títol original",
"components.TvDetails.originallanguage": "Idioma original",
"components.TvDetails.nextAirDate": "Pròxima data d'emissió",
"components.TvDetails.network": "{networkCount, plural, one {Plataforma} other {Plataformes}}",
"components.TvDetails.network": "{networkCount, plural, one {Emissor} other {Emissors}}",
"components.TvDetails.firstAirDate": "Primera data d'emissió",
"components.TvDetails.episodeRuntimeMinutes": "{runtime} minuts",
"components.TvDetails.episodeRuntime": "Duració de l'episodi",
@@ -548,7 +548,7 @@
"components.Settings.SettingsJobsCache.cachekeys": "Claus totals",
"components.Settings.SettingsJobsCache.cacheflushed": "La memòria cau de {cachename} s'ha buidat.",
"components.Settings.SettingsAbout.totalrequests": "Sol·licituds totals",
"components.Settings.SettingsAbout.totalmedia": "Total de suports multimèdia",
"components.Settings.SettingsAbout.totalmedia": "Total de contingut",
"components.Settings.SettingsAbout.preferredmethod": "Preferit",
"components.Settings.SettingsAbout.githubdiscussions": "Debats de GitHub",
"components.Settings.SettingsAbout.Releases.viewongithub": "Visualitza a GitHub",
@@ -759,7 +759,7 @@
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSending": "S'està enviant la notificació de prova de Pushbullet…",
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestFailed": "No s'ha pogut enviar la notificació de prova Pushbullet.",
"components.Settings.Notifications.NotificationsLunaSea.webhookUrl": "URL del Webhook",
"components.Settings.Notifications.NotificationsLunaSea.validationWebhookUrl": "Heu de proporcionar un URL vàlid",
"components.Settings.Notifications.NotificationsLunaSea.validationWebhookUrl": "Has de proporcionar un URL vàlid",
"components.Settings.Notifications.NotificationsLunaSea.toastLunaSeaTestSuccess": "S'ha enviat la notificació de prova de LunaSea!",
"components.Settings.Notifications.NotificationsLunaSea.toastLunaSeaTestSending": "S'està enviant la notificació de prova de LunaSea…",
"components.Settings.Notifications.NotificationsLunaSea.settingsSaved": "La configuració de les notificacions de LunaSea s'ha desat correctament!",
@@ -805,12 +805,12 @@
"components.QuotaSelector.movies": "{count, plural, one {pel·lícula} other {pel·lícules}}",
"components.QuotaSelector.movieRequests": "{quotaLimit} <quotaUnits>{pel·lícules} per {quotaDays} {dies}</quotaUnits>",
"components.QuotaSelector.days": "{count, plural, one {dia} other {dies}}",
"components.NotificationTypeSelector.usermediarequestedDescription": "Rep notificacions quan altres usuaris envien sol·licituds de mitjans nous que requereixen aprovació.",
"components.NotificationTypeSelector.usermediafailedDescription": "Rep notificacions quan no es puguin afegir sol·licituds a Radarr o Sonarr.",
"components.NotificationTypeSelector.usermediadeclinedDescription": "Rep notificacions quan es rebutgin les teves sol·licituds.",
"components.NotificationTypeSelector.usermediaavailableDescription": "Rep notificacions quan les teves sol·licituds multimèdia estiguin disponibles.",
"components.NotificationTypeSelector.usermediaapprovedDescription": "Rep notificacions quan s'aprovin les teves sol·licituds de suports.",
"components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Rep notificacions quan altres usuaris envien sol·licituds de mitjans nous que saprovin automàticament.",
"components.NotificationTypeSelector.usermediarequestedDescription": "Rep notificacions quan altres usuaris envien sol·licituds de contingut noves que requereixen aprovació.",
"components.NotificationTypeSelector.usermediafailedDescription": "Rep notificacions quan no es puguin afegir sol·licituds de contingut a Radarr o Sonarr.",
"components.NotificationTypeSelector.usermediadeclinedDescription": "Rep notificacions quan es rebutgin les teves sol·licituds de contingut.",
"components.NotificationTypeSelector.usermediaavailableDescription": "Rep notificacions quan les teves sol·licituds de contingut estiguin disponibles.",
"components.NotificationTypeSelector.usermediaapprovedDescription": "Rep notificacions quan s'aprovin les teves sol·licituds de contingut.",
"components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Rep notificacions quan altres usuaris envien sol·licituds de contingut noves que saprovin automàticament.",
"components.MovieDetails.showmore": "Mostra més",
"components.MovieDetails.showless": "Mostra menys",
"components.Layout.LanguagePicker.displaylanguage": "Idioma de visualització",
@@ -835,7 +835,7 @@
"components.IssueDetails.deleteissueconfirm": "Esteu segur que voleu suprimir aquesta incidència?",
"components.IssueDetails.episode": "Episodi {episodeNumber}",
"components.IssueDetails.lastupdated": "Última actualització",
"components.IssueDetails.openinarr": "Oberta en {arr}",
"components.IssueDetails.openinarr": "Obre a {arr}",
"components.IssueDetails.toasteditdescriptionfailed": "S'ha produït un error en editar la descripció de la incidència.",
"components.IssueDetails.toastissuedeletefailed": "S'ha produït un error en suprimir la incidència.",
"components.IssueDetails.unknownissuetype": "Desconegut",
@@ -964,8 +964,8 @@
"components.Settings.Notifications.NotificationsGotify.validationTokenRequired": "Heu de proporcionar un testimoni d'aplicació",
"components.UserProfile.UserSettings.UserGeneralSettings.discordIdTip": "El <FindDiscordIdLink>número d'ID de diversos dígits</FindDiscordIdLink> associat al vostre compte d'usuari de Discord",
"components.ManageSlideOver.manageModalAdvanced": "Avançat",
"components.ManageSlideOver.manageModalMedia": "Mitjans",
"components.ManageSlideOver.manageModalMedia4k": "Mitjans 4K",
"components.ManageSlideOver.manageModalMedia": "Contingut",
"components.ManageSlideOver.manageModalMedia4k": "Contingut 4K",
"components.ManageSlideOver.alltime": "Tots els temps",
"components.ManageSlideOver.markallseasons4kavailable": "Marqueu totes les temporades com a disponibles en 4K",
"components.ManageSlideOver.markallseasonsavailable": "Marqueu totes les temporades com a disponibles",
@@ -987,7 +987,7 @@
"components.Settings.Notifications.NotificationsPushbullet.channelTag": "Etiqueta de canal",
"components.Settings.externalUrl": "URL extern",
"components.Settings.tautulliSettings": "Configuració de Tautulli",
"components.Settings.tautulliSettingsDescription": "Opcionalment podeu configurar del vostre servidor Tautulli. Overseerr recupera les dades de l'historial de visualitzacions dels vostres mitjans Plex de Tautulli.",
"components.Settings.tautulliSettingsDescription": "Opcionalment podeu configurar del vostre servidor Tautulli. Overseerr recupera les dades de l'historial de visualitzacions dels vostres continguts Plex de Tautulli.",
"components.Settings.toastTautulliSettingsFailure": "Alguna cosa ha fallat en desar la configuració de Tautulli.",
"components.Settings.toastTautulliSettingsSuccess": "La configuració de Tautulli s'ha desat correctament!",
"components.Settings.urlBase": "URL base",
@@ -1006,7 +1006,7 @@
"components.MovieDetails.digitalrelease": "Llançament digital",
"components.MovieDetails.physicalrelease": "Alliberament físic",
"components.PermissionEdit.viewrecent": "Veure afegits recentment",
"components.PermissionEdit.viewrecentDescription": "Concediu permís per veure la llista de mitjans afegits recentment.",
"components.PermissionEdit.viewrecentDescription": "Concediu permís per veure la llista de contingut afegits recentment.",
"components.Settings.deleteServer": "Suprimeix el servidor {serverType}",
"components.StatusChecker.reloadApp": "Torna a carregar {applicationTitle}",
"components.TitleCard.cleardata": "Esborra les dades",
@@ -1033,7 +1033,7 @@
"components.Discover.DiscoverWatchlist.watchlist": "Llista de seguiment de Plex",
"components.MovieDetails.managemovie": "Gestiona la pel·lícula",
"components.MovieDetails.theatricalrelease": "Estrena en cines",
"components.PermissionEdit.autorequestDescription": "Concediu permís per enviar automàticament sol·licituds de mitjans que no siguin 4K mitjançant llistes de seguiment de Plex.",
"components.PermissionEdit.autorequestDescription": "Concediu permís per enviar automàticament sol·licituds de contingut que no siguin 4K mitjançant llistes de seguiment de Plex.",
"components.PermissionEdit.viewwatchlists": "Veure les llistes de seguiment de Plex",
"components.PermissionEdit.viewwatchlistsDescription": "Concediu permís per veure les llistes de seguiment de Plex d'altres usuaris.",
"components.RequestCard.tvdbid": "Identificador de TheTVDB",
@@ -1046,7 +1046,7 @@
"components.Layout.UserDropdown.MiniQuotaDisplay.movierequests": "Sol·licituds de pel·lícules",
"components.Layout.UserDropdown.MiniQuotaDisplay.seriesrequests": "Sol·licituds de sèries",
"components.MovieDetails.reportissue": "Informa d'un problema",
"components.StatusChecker.appUpdatedDescription": "Feu clic al botó següent per tornar a carregar l'aplicació.",
"components.StatusChecker.appUpdatedDescription": "Fes clic al botó següent per a tornar a carregar l'aplicació.",
"components.StatusChecker.appUpdated": "Actualitzat {applicationTitle}",
"components.StatusChecker.restartRequiredDescription": "Si us plau, reinicieu el servidor per aplicar la configuració actualitzada.",
"components.AirDateBadge.airsrelative": "Emissió {relativeTime}",
@@ -1079,10 +1079,10 @@
"components.TvDetails.tmdbuserscore": "Puntuació d'usuaris de TMDB",
"components.TvDetails.status4k": "4K {status}",
"components.StatusChecker.restartRequired": "Cal reiniciar el servidor",
"components.Discover.emptywatchlist": "Els mitjans afegits a la vostra <PlexWatchlistSupportLink>Llista de seguiment de Plex</PlexWatchlistSupportLink> apareixeran aquí.",
"components.Discover.emptywatchlist": "Els continguts afegits a la vostra <PlexWatchlistSupportLink>Llista de seguiment de Plex</PlexWatchlistSupportLink> apareixeran aquí.",
"components.RequestModal.requestcollection4ktitle": "Sol·licitud de col·lecció en 4K",
"components.RequestModal.SearchByNameModal.nomatches": "No hem pogut trobar cap coincidència per a aquesta sèrie.",
"components.UserProfile.emptywatchlist": "Els mitjans afegits a la vostra <PlexWatchlistSupportLink>Llista de seguiment de Plex</PlexWatchlistSupportLink> apareixeran aquí.",
"components.UserProfile.emptywatchlist": "Els continguts afegits a la vostra <PlexWatchlistSupportLink>Llista de seguiment de Plex</PlexWatchlistSupportLink> apareixeran aquí.",
"components.RequestModal.requestseries4ktitle": "Sol·licitud de sèries en 4K",
"components.RequestModal.requestcollectiontitle": "Sol·licitud de col·lecció",
"components.RequestModal.requestmovie4ktitle": "Sol·licitud de pel·lícula en 4K",
@@ -1094,5 +1094,135 @@
"components.Settings.SettingsJobsCache.imagecache": "Memòria cau d'imatges",
"components.Settings.SettingsJobsCache.imagecachecount": "Imatges a la memòria cau",
"components.Settings.SettingsJobsCache.imagecachesize": "Mida total de la memòria cau",
"components.Settings.SettingsJobsCache.imagecacheDescription": "Quan està activat a la configuració, Overseerr enviarà les imatges a la memòria cau de fonts externes preconfigurades. Les imatges emmagatzemades a la memòria cau es desen a la vostra carpeta de configuració. Podeu trobar els fitxers a <code>{appDataPath}/cache/images</code>."
"components.Settings.SettingsJobsCache.imagecacheDescription": "Quan està activat a la configuració, Overseerr enviarà les imatges a la memòria cau de fonts externes preconfigurades. Les imatges emmagatzemades a la memòria cau es desen a la vostra carpeta de configuració. Podeu trobar els fitxers a <code>{appDataPath}/cache/images</code>.",
"components.Discover.DiscoverMovieKeyword.keywordMovies": "Pel·lícules {keywordTitle}",
"components.Discover.tmdbtvgenre": "Gènere de la sèrie TMDB",
"components.Discover.tmdbtvkeyword": "Paraula clau de la sèrie TMDB",
"components.Discover.CreateSlider.providetmdbkeywordid": "Proporciona un ID de paraula clau de TMDB",
"components.Discover.CreateSlider.providetmdbsearch": "Proporciona una consulta de cerca",
"components.Discover.CreateSlider.searchStudios": "Cercar per estudi…",
"components.Discover.CreateSlider.slidernameplaceholder": "Nom del control lliscant",
"components.Discover.CreateSlider.starttyping": "Començar a escriure per a cercar.",
"components.Discover.DiscoverSliderEdit.enable": "Commutar la visibilitat",
"components.Discover.DiscoverSliderEdit.remove": "Suprimir",
"components.Discover.PlexWatchlistSlider.plexwatchlist": "La teva llista Plex",
"components.Discover.RecentlyAddedSlider.recentlyAdded": "Afegit recentment",
"components.Discover.CreateSlider.addSlider": "Afegir un control lliscant",
"components.Discover.CreateSlider.addcustomslider": "Crear un control lliscant personalitzat",
"components.Discover.CreateSlider.addfail": "No s'ha pogut crear un control lliscant nou.",
"components.Discover.CreateSlider.editSlider": "Editar el control lliscant",
"components.Discover.CreateSlider.editfail": "No s'ha pogut editar el control lliscant.",
"components.Discover.tmdbmoviegenre": "Gènere de pel·lícula TMDB",
"components.Discover.tmdbsearch": "Cerca TMDB",
"components.Discover.tmdbstudio": "Estudi TMDB",
"components.Discover.CreateSlider.editsuccess": "S'ha editat el control lliscant i s'ha desat la configuració de la pàgina de Descobriu.",
"components.Discover.CreateSlider.needresults": "Cal tenir almenys 1 resultat.",
"components.Discover.CreateSlider.nooptions": "Sense resultats.",
"components.Discover.CreateSlider.providetmdbgenreid": "Proporciona un ID de categoria TMDB",
"components.Discover.CreateSlider.providetmdbnetwork": "Proporciona l'ID d'emissor TMDB",
"components.Discover.CreateSlider.providetmdbstudio": "Proporciona l'ID d'estudi TMDB",
"components.Discover.CreateSlider.searchGenres": "Cercar per gènere…",
"components.Discover.CreateSlider.searchKeywords": "Cercar per paraules clau…",
"components.Discover.DiscoverSliderEdit.deletefail": "No s'ha pogut suprimir el control lliscant.",
"components.Discover.PlexWatchlistSlider.emptywatchlist": "El contingut afegit a la teva <PlexWatchlistSupportLink>llista de visualització de Plex</PlexWatchlistSupportLink> apareixerà aquí.",
"components.Discover.CreateSlider.addsuccess": "S'ha creat un control lliscant nou i s'ha desat la configuració de personalització de Descobriu.",
"components.Discover.CreateSlider.validationDatarequired": "Has de proporcionar un valor de dades.",
"components.Discover.CreateSlider.validationTitlerequired": "Has de proporcionar un títol.",
"components.Discover.DiscoverSliderEdit.deletesuccess": "Control lliscant suprimit amb èxit.",
"components.Discover.createnewslider": "Crear un control lliscant nou",
"components.Discover.DiscoverMovies.discovermovies": "Pel·lícules",
"components.Discover.DiscoverMovies.sortPopularityAsc": "Popularitat ascendent",
"components.Discover.DiscoverMovies.activefilters": "{count, plural, one {# filtre actiu} other {# filtres actius}}",
"components.Discover.DiscoverMovies.sortPopularityDesc": "Popularitat descendent",
"components.Discover.DiscoverMovies.sortReleaseDateAsc": "Data de llançament ascendent",
"components.Discover.DiscoverMovies.sortReleaseDateDesc": "Data de llançament descendent",
"components.Discover.DiscoverMovies.sortTitleAsc": "Títol (A-Z) ascendent",
"components.Discover.DiscoverMovies.sortTitleDesc": "Títol (Z-A) descendent",
"components.Discover.DiscoverMovies.sortTmdbRatingAsc": "Puntuació TMDB ascendent",
"components.Discover.DiscoverMovies.sortTmdbRatingDesc": "Puntuació TMDB descendent",
"components.Discover.DiscoverTv.activefilters": "{count, plural, one {# filtre actiu} other {# filtres actius}}",
"components.Discover.DiscoverTv.discovertv": "Sèries",
"components.Discover.DiscoverTv.sortFirstAirDateAsc": "Data de primera d'emissió ascendent",
"components.Discover.DiscoverTv.sortFirstAirDateDesc": "Data de primera d'emissió descendent",
"components.Discover.DiscoverTv.sortPopularityAsc": "Popularitat ascendent",
"components.Discover.DiscoverTv.sortPopularityDesc": "Popularitat descendent",
"components.Discover.DiscoverTv.sortTmdbRatingAsc": "Puntuació TMDB ascendent",
"components.Discover.DiscoverTv.sortTmdbRatingDesc": "Puntuació TMDB descendent",
"components.Discover.networks": "Emissors",
"components.Discover.resetwarning": "Restablir tots els controls lliscants al valor predeterminat. Això també suprimirà els controls lliscants personalitzats!",
"components.Discover.tmdbmoviekeyword": "Paraula clau de pel·lícula TMDB",
"components.Discover.tmdbnetwork": "Emissors TMDB",
"components.Discover.FilterSlideover.tmdbuserscore": "Puntuació d'usuaris TMDB",
"components.Discover.tvgenres": "Gèneres de sèries",
"components.Discover.DiscoverTvKeyword.keywordSeries": "Sèries {keywordTitle}",
"components.Discover.moviegenres": "Gèneres de pel·lícules",
"components.Discover.resetsuccess": "Restablerta la configuració personalitzada de Descobriu.",
"components.Discover.studios": "Estudis",
"components.Discover.stopediting": "Parar l'edició",
"components.Discover.updatefailed": "S'ha produït un error en actualitzar la configuració de personalitzada de Descobriu.",
"components.Discover.updatesuccess": "S'ha actualitzat la configuració de personalitzada de Descobriu.",
"components.Discover.FilterSlideover.releaseDate": "Data de publicació",
"components.Discover.FilterSlideover.runtime": "Temps d'execució",
"components.RequestList.RequestItem.unknowntitle": "Títol desconegut",
"components.RequestCard.unknowntitle": "Títol desconegut",
"components.Selector.nooptions": "Sense resultats.",
"components.Discover.DiscoverTv.sortTitleAsc": "Títol (A-Z) ascendent",
"components.Discover.DiscoverTv.sortTitleDesc": "Títol (Z-A) descendent",
"components.Discover.FilterSlideover.activefilters": "{count, plural, one {# filtre actiu} other {# filtres actius}}",
"components.Discover.FilterSlideover.clearfilters": "Netejar els filtres actius",
"components.Discover.FilterSlideover.filters": "Filtres",
"components.Discover.FilterSlideover.firstAirDate": "Data primera d'emissió",
"components.Discover.FilterSlideover.from": "Des de",
"components.Discover.FilterSlideover.genres": "Gèneres",
"components.Discover.FilterSlideover.keywords": "Paraules clau",
"components.Discover.FilterSlideover.originalLanguage": "Idioma original",
"components.Discover.FilterSlideover.ratingText": "Puntuació entre {minValue} i {maxValue}",
"components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} durada en minuts",
"components.Discover.resetfailed": "S'ha produït un error en restablir la configuració de personalització de Descobriu.",
"components.Discover.customizediscover": "Personalitza Descobriu",
"components.Discover.resettodefault": "Restablir al valor predeterminat",
"components.Layout.Sidebar.browsemovies": "Pel·lícules",
"components.Layout.Sidebar.browsetv": "Sèries",
"components.Selector.searchGenres": "Seleccionar gèneres…",
"components.DownloadBlock.formattedTitle": "{title}: Temporada {temporadaNumber} Episodi {episodeNumber}",
"components.Discover.FilterSlideover.streamingservices": "Serveis en streaming",
"components.Discover.FilterSlideover.studio": "Estudi",
"components.Discover.FilterSlideover.to": "A",
"components.Settings.SettingsMain.csrfProtection": "Habilitar la protecció CSRF",
"components.Settings.SettingsMain.general": "General",
"components.Settings.SettingsMain.generalsettings": "Configuració general",
"components.Settings.SettingsMain.cacheImages": "Activar la memòria cau d'imatges",
"components.Settings.SettingsMain.csrfProtectionHoverTip": "NO activis aquesta configuració tret que entenguis el que estàs fent!",
"components.Settings.SettingsMain.csrfProtectionTip": "Establir l'accés a l'API extern a només de lectura (requereix HTTPS)",
"components.Settings.SettingsMain.generalsettingsDescription": "Configuració global i predeterminada per a Overseerr.",
"components.Settings.SettingsMain.hideAvailable": "Amagar el contingut disponible",
"components.Settings.SettingsMain.apikey": "Clau API",
"components.Settings.SettingsMain.applicationurl": "URL de l'aplicació",
"components.Settings.SettingsMain.cacheImagesTip": "Memòria cau d'imatges d'origen extern (requereix una quantitat important d'espai en disc)",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Cada {jobScheduleSeconds, plural, one {segon} other {{jobScheduleSeconds} segons}}",
"components.Settings.SettingsMain.applicationTitle": "Títol de l'aplicació",
"components.Selector.searchKeywords": "Cercar paraules clau…",
"components.Selector.starttyping": "Començar a escriure per a cercar.",
"components.Selector.searchStudios": "Cercar estudis…",
"components.Selector.showless": "Mostrar menys",
"components.Selector.showmore": "Mostrar més",
"components.Settings.SettingsMain.locale": "Idioma de visualització",
"components.Settings.SettingsMain.originallanguage": "Idioma a Descobriu",
"components.Settings.SettingsMain.originallanguageTip": "Filtrar el contingut per idioma original",
"components.Settings.SettingsMain.regionTip": "Filtrar continguts per disponibilitat regional",
"components.Settings.SettingsMain.toastApiKeyFailure": "S'ha produït un error en generar una clau API nova.",
"components.Settings.SettingsMain.toastApiKeySuccess": "La clau API s'ha generat correctament!",
"components.Settings.SettingsMain.toastSettingsFailure": "S'ha produït un error en desar la configuració.",
"components.Settings.SettingsMain.partialRequestsEnabled": "Permet sol·licituds parcials de sèries",
"components.Settings.SettingsMain.region": "Regió de Descobriu",
"components.Settings.SettingsMain.toastSettingsSuccess": "La configuració s'ha desat correctament!",
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "L'URL no ha d'acabar amb una barra inclinada final",
"components.Settings.SettingsMain.trustProxy": "Habilitar la compatibilitat amb proxy",
"components.Settings.SettingsMain.trustProxyTip": "Permetre a Overserr registrar correctament la IP del client darrere d'un proxy",
"components.Settings.SettingsMain.validationApplicationTitle": "Has de proporcionar un títol d'aplicació",
"components.Settings.SettingsMain.validationApplicationUrl": "Has de proporcionar un URL vàlid",
"components.StatusBadge.seasonepisodenumber": "S{seasonNumber}E{episodeNumber}",
"components.Settings.SettingsJobsCache.availability-sync": "Sincronització de disponibilitat de contingut",
"components.Discover.tmdbmoviestreamingservices": "Serveis de transmissió de pel·lícules TMDB",
"components.Discover.tmdbtvstreamingservices": "Serveis de transmissió de TV TMDB"
}

View File

@@ -228,7 +228,7 @@
"components.NotificationTypeSelector.usermediaapprovedDescription": "Získat upozornění na schválení vašich žádostí o média.",
"components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Získat upozornění, když ostatní uživatelé zadají nové požadavky na média, která jsou automaticky schválena.",
"components.NotificationTypeSelector.mediarequestedDescription": "Odeslat oznámení, když uživatelé zažádají o média vyžadující schválení.",
"components.MovieDetails.streamingproviders": "Aktuálně streamovatelné na",
"components.MovieDetails.streamingproviders": "Aktuálně streamované na",
"pages.serviceunavailable": "Služba není k dispozici",
"pages.returnHome": "Vrátit se domů",
"pages.pagenotfound": "Stránka nebyla nalezena",
@@ -316,20 +316,20 @@
"components.NotificationTypeSelector.mediafailed": "Zpracování požadavku se nezdařilo",
"components.NotificationTypeSelector.mediadeclinedDescription": "Odeslat oznámení, pokud jsou požadavky na média odmítnuty.",
"components.NotificationTypeSelector.mediadeclined": "Žádost zamítnuta",
"components.NotificationTypeSelector.mediaavailableDescription": "Odeslat oznámení, jakmile budou k dispozici žádosti o média.",
"components.NotificationTypeSelector.mediaavailable": "Žádost k dispozici",
"components.NotificationTypeSelector.mediaapprovedDescription": "Odeslat oznámení, když jsou požadavky na média ručně schváleny.",
"components.NotificationTypeSelector.mediaavailableDescription": "Odesílat oznámení, když jsou žádosti o média dostupné.",
"components.NotificationTypeSelector.mediaavailable": "Žádost je dostupná",
"components.NotificationTypeSelector.mediaapprovedDescription": "Odesílat upozornění, když jsou žádosti o média ručně schváleny.",
"components.NotificationTypeSelector.mediaapproved": "Žádost schválena",
"components.NotificationTypeSelector.mediaAutoApprovedDescription": "Odeslat oznámení, když uživatelé zadají nové požadavky na média, která jsou automaticky schválena.",
"components.NotificationTypeSelector.mediaAutoApprovedDescription": "Odesílat upozornění, když uživatelé odešlou nové žádosti o média, které jsou automaticky schváleny.",
"components.NotificationTypeSelector.mediaAutoApproved": "Žádost automaticky schválena",
"components.MovieDetails.watchtrailer": "Sledovat trailer",
"components.MovieDetails.watchtrailer": "Sledovat upoutávku",
"components.MovieDetails.viewfullcrew": "Zobrazit kompletní štáb",
"components.MovieDetails.similar": "Podobné tituly",
"components.MovieDetails.showmore": "Zobrazit více",
"components.MovieDetails.showless": "Zobrazit méně",
"components.MovieDetails.runtime": "{minutes} minut",
"components.MovieDetails.runtime": "{minutes} {minutes, plural, one {minuta} few {minuty} other {minut}}",
"components.MovieDetails.revenue": "Výnos",
"components.MovieDetails.releasedate": "{releaseCount, plural, one {Dátum vydání} other {Dátumy vydání}}",
"components.MovieDetails.releasedate": "{releaseCount, plural, one {Datum vydání} other {Data vydání}}",
"components.MovieDetails.recommendations": "Doporučení",
"components.MovieDetails.playonplex": "Přehrát v Plexu",
"components.MovieDetails.play4konplex": "Přehrát v Plexu ve 4K",
@@ -346,8 +346,8 @@
"components.MediaSlider.ShowMoreCard.seemore": "Zobrazit více",
"components.Login.validationpasswordrequired": "Musíte zadat heslo",
"components.Login.validationemailrequired": "Musíte zadat platnou e-mailovou adresu",
"components.Login.signinwithplex": "Použijte svůj Plex účet",
"components.Login.signinwithoverseerr": "Použijte svůj {applicationTitle} účet",
"components.Login.signinwithplex": "Použijte svůj účet Plex",
"components.Login.signinwithoverseerr": "Použijte svůj účet {applicationTitle}",
"components.Login.signinheader": "Pro pokračování se přihlaste",
"components.Login.signingin": "Přihlašování…",
"components.Login.signin": "Přihlásit se",
@@ -355,8 +355,8 @@
"components.Login.loginerror": "Při pokusu o přihlášení se něco pokazilo.",
"components.Login.forgotpassword": "Zapomenuté heslo?",
"components.Login.email": "E-mailová adresa",
"components.Layout.VersionStatus.streamstable": "Overseerr Stabilní",
"components.Layout.VersionStatus.streamdevelop": "Overseerr Vývoj",
"components.Layout.VersionStatus.streamstable": "Stabilní verze Overseerr",
"components.Layout.VersionStatus.streamdevelop": "Vývojová verze Overseerr",
"components.Layout.VersionStatus.outofdate": "Zastaralý",
"components.Layout.UserDropdown.signout": "Odhlásit se",
"components.Layout.UserDropdown.settings": "Nastavení",
@@ -365,41 +365,41 @@
"components.Layout.Sidebar.settings": "Nastavení",
"components.Layout.Sidebar.requests": "Žádosti",
"components.Layout.Sidebar.dashboard": "Objevit",
"components.Layout.SearchInput.searchPlaceholder": "Vyhledat Filmy a Seriály",
"components.Layout.SearchInput.searchPlaceholder": "Hledat filmy a seriály",
"components.Layout.LanguagePicker.displaylanguage": "Jazyk zobrazení",
"components.LanguageSelector.originalLanguageDefault": "Všechny jazyky",
"components.LanguageSelector.languageServerDefault": "Výchozí ({language})",
"components.DownloadBlock.estimatedtime": "Odhadovaný {time}",
"components.Discover.upcomingtv": "Nadcházející Seriály",
"components.DownloadBlock.estimatedtime": "Odhadováno {time}",
"components.Discover.upcomingtv": "Nadcházející seriály",
"components.Discover.upcomingmovies": "Nadcházející filmy",
"components.Discover.upcoming": "Nadcházející filmy",
"components.Discover.trending": "Populární",
"components.Discover.recentrequests": "Nedávné žádosti",
"components.Discover.recentlyAdded": "Nedávno přidané",
"components.Discover.populartv": "Populární Seriály",
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Seriály",
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Seriály",
"components.Discover.DiscoverNetwork.networkSeries": "{network} Seriály",
"components.Discover.popularmovies": "Populární filmy",
"components.Discover.populartv": "Oblíbené seriály",
"components.Discover.DiscoverTvLanguage.languageSeries": "Seriály: {language}",
"components.Discover.DiscoverTvGenre.genreSeries": "Seriály: {genre}",
"components.Discover.DiscoverNetwork.networkSeries": "Seriály: {network}",
"components.Discover.popularmovies": "Oblíbené filmy",
"components.Discover.discover": "Objevte",
"components.Discover.TvGenreSlider.tvgenres": "Žánry seriálů",
"components.Discover.TvGenreList.seriesgenres": "Žánry seriálů",
"components.Discover.StudioSlider.studios": "Studia",
"components.Discover.NetworkSlider.networks": "Streamovací platformy",
"components.Discover.MovieGenreSlider.moviegenres": "Filmové žánry",
"components.Discover.MovieGenreList.moviegenres": "Filmové žánry",
"components.CollectionDetails.numberofmovies": "{count} Filmů",
"components.AppDataWarning.dockerVolumeMissingDescription": "Připojení svazku <code>{appDataPath}</code> nebylo správně nakonfigurováno. Všechna data budou vymazána při zastavení nebo opětovném spuštění kontejneru.",
"components.Discover.DiscoverStudio.studioMovies": "{studio} Filmy",
"components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Filmy",
"components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Filmy",
"components.CollectionDetails.requestcollection4k": "Požádat o kolekci ve 4K",
"components.CollectionDetails.requestcollection": "Požádat o kolekci",
"components.Discover.NetworkSlider.networks": "Stanice",
"components.Discover.MovieGenreSlider.moviegenres": "Žánry filmů",
"components.Discover.MovieGenreList.moviegenres": "Žánry filmů",
"components.CollectionDetails.numberofmovies": "{count} filmů",
"components.AppDataWarning.dockerVolumeMissingDescription": "Připojení svazku <code>{appDataPath}</code> nebylo správně nakonfigurováno. Při zastavení nebo opětovném spuštění kontejneru budou všechna data vymazána.",
"components.Discover.DiscoverStudio.studioMovies": "Filmy: {studio}",
"components.Discover.DiscoverMovieLanguage.languageMovies": "Filmy: {language}",
"components.Discover.DiscoverMovieGenre.genreMovies": "Filmy: {genre}",
"components.CollectionDetails.requestcollection4k": "Zažádat o kolekci ve 4K",
"components.CollectionDetails.requestcollection": "Zažádat o kolekci",
"components.CollectionDetails.overview": "Přehled",
"components.Settings.SettingsJobsCache.cachemisses": "Neúspěchy",
"components.NotificationTypeSelector.usermediadeclinedDescription": "Dostat oznámení o odmítnutí vašich požadavků na média.",
"components.NotificationTypeSelector.usermediaavailableDescription": "Dostat oznámení, jakmile budou k dispozici žádosti o média.",
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {commit} other {commitů}} za",
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {potvrzení} few {potvrzení} other {potvrzení}} za",
"components.Setup.configureplex": "Konfigurovat Plex",
"components.Settings.serverpresetRefreshing": "Načítání serverů…",
"components.Settings.plexsettings": "Nastavení Plexu",
@@ -424,10 +424,10 @@
"components.RequestModal.numberofepisodes": "Počet epizod",
"components.IssueDetails.issuepagetitle": "Problém",
"components.IssueDetails.leavecomment": "Komentář",
"components.ManageSlideOver.tvshow": "série",
"components.ManageSlideOver.tvshow": "seriál",
"i18n.open": "Otevřené",
"components.IssueDetails.issuetype": "Typ",
"components.IssueList.IssueItem.opened": "Otevřené",
"components.IssueList.IssueItem.opened": "Otevřeno",
"components.IssueList.IssueItem.unknownissuetype": "Neznámý",
"components.IssueList.issues": "Problémy",
"components.ManageSlideOver.manageModalRequests": "Žádosti",
@@ -447,7 +447,7 @@
"components.IssueModal.CreateIssueModal.toastviewissue": "Zobrazit problém",
"components.ManageSlideOver.manageModalNoRequests": "Žádné žádosti.",
"components.IssueList.IssueItem.issuetype": "Typ",
"components.IssueDetails.problemseason": "Ovlivněná série",
"components.IssueDetails.problemseason": "Ovlivněná sezóna",
"components.IssueDetails.reopenissue": "Znovu otevřít problém",
"components.NotificationTypeSelector.issuecreated": "Problém nahlášen",
"components.PermissionEdit.viewissues": "Zobrazit problémy",
@@ -455,7 +455,7 @@
"components.IssueModal.CreateIssueModal.problemepisode": "Ovlivněná epizoda",
"components.IssueDetails.IssueComment.delete": "Odstranit komentář",
"components.IssueDetails.IssueComment.edit": "Upravit komentář",
"components.IssueDetails.allseasons": "Všechny série",
"components.IssueDetails.allseasons": "Všechny sezóny",
"components.IssueDetails.closeissue": "Uzavřít problém",
"components.ManageSlideOver.downloadstatus": "Stahování",
"components.NotificationTypeSelector.issueresolved": "Problém vyřešen",
@@ -485,16 +485,16 @@
"components.IssueDetails.nocomments": "Žádné komentáře.",
"components.IssueList.IssueItem.issuestatus": "Stav",
"components.IssueDetails.problemepisode": "Ovlivněná epizoda",
"components.IssueDetails.season": "Série {seasonNumber}",
"components.IssueDetails.season": "Sezóna {seasonNumber}",
"components.IssueList.IssueItem.problemepisode": "Ovlivněná epizoda",
"components.IssueList.IssueItem.viewissue": "Zobrazit problém",
"components.IssueList.sortAdded": "Nejnovější",
"components.IssueList.sortModified": "Naposledy změněno",
"components.IssueModal.CreateIssueModal.allepisodes": "Všechny epizody",
"components.IssueModal.CreateIssueModal.allseasons": "Všechny série",
"components.IssueModal.CreateIssueModal.allseasons": "Všechny sezóny",
"components.IssueModal.CreateIssueModal.episode": "Epizoda {episodeNumber}",
"components.IssueModal.CreateIssueModal.problemseason": "Ovlivněná série",
"components.IssueModal.CreateIssueModal.season": "Série {seasonNumber}",
"components.IssueModal.CreateIssueModal.season": "Sezóna {seasonNumber}",
"components.IssueModal.CreateIssueModal.submitissue": "Odeslat problém",
"components.ManageSlideOver.manageModalTitle": "Spravovat {mediaType}",
"components.PermissionEdit.createissues": "Nahlásit problémy",
@@ -524,46 +524,46 @@
"components.UserProfile.UserSettings.UserNotificationSettings.telegramChatId": "ID chatu",
"components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Potvrďte heslo",
"components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Aktuální heslo",
"components.IssueDetails.toastissuedeleted": "Problém byl úspěšně smazán!",
"components.IssueDetails.toaststatusupdated": "Stav problému byl úspěšně aktualizován!",
"components.IssueDetails.toastissuedeleted": "Problém úspěšně odstraněn!",
"components.IssueDetails.toaststatusupdated": "Stav problému úspěšně aktualizován!",
"components.ManageSlideOver.markavailable": "Označit jako dostupné",
"components.IssueDetails.commentplaceholder": "Přidat komentář…",
"components.IssueDetails.openedby": "#{issueId} otevřeno {relativeTime} uživatelem {username}",
"components.IssueDetails.openedby": "Č. {issueId} otevřeno {relativeTime} uživatelem {username}",
"components.IssueDetails.openin4karr": "Otevřít ve 4K {arr}",
"components.IssueDetails.reopenissueandcomment": "Znovu otevřít pomocí komentáře",
"components.IssueDetails.reopenissueandcomment": "Znovu otevřít s komentářem",
"components.IssueModal.CreateIssueModal.validationMessageRequired": "Musíte poskytnout popis",
"components.ManageSlideOver.manageModalClearMedia": "Vyčistit data",
"components.ManageSlideOver.alltime": "Pořád",
"components.ManageSlideOver.alltime": "Za celou dobu",
"components.ManageSlideOver.manageModalAdvanced": "Pokročilý",
"components.ManageSlideOver.manageModalClearMediaWarning": "* Tímto nenávratně odstraníte všechna data pro tento {mediaType}, včetně všech požadavků. Pokud tato položka existuje ve vaší knihovně Plex, informace o médiích budou znovu vytvořeny během příštího skenování.",
"components.ManageSlideOver.manageModalClearMediaWarning": "* Tímto nenávratně odeberete všechna data pro tento {mediaType}, včetně všech požadavků. Pokud tato položka existuje ve vaší knihovně Plex, informace o médiích budou znovu vytvořeny během příštího skenování.",
"components.ManageSlideOver.manageModalMedia": "Média",
"components.ManageSlideOver.manageModalMedia4k": "4K Média",
"components.ManageSlideOver.markallseasonsavailable": "Označte všechny sezóny jako dostupné",
"components.ManageSlideOver.markallseasons4kavailable": "Označte všechny sezóny jako dostupné ve 4K",
"components.ManageSlideOver.manageModalMedia4k": "4K média",
"components.ManageSlideOver.markallseasonsavailable": "Označit všechny sezóny jako dostupné",
"components.ManageSlideOver.markallseasons4kavailable": "Označit všechny sezóny jako dostupné ve 4K",
"components.ManageSlideOver.openarr": "Otevřít v {arr}",
"components.ManageSlideOver.openarr4k": "Otevřít ve 4K {arr}",
"components.ManageSlideOver.opentautulli": "Otevřít v Tautulli",
"components.NotificationTypeSelector.adminissuecommentDescription": "Buďte upozorněn, když ostatní uživatelé komentují problémy.",
"components.NotificationTypeSelector.adminissuereopenedDescription": "Buďte upozorněn, když problémy znovu otevřou jiní uživatelé.",
"components.NotificationTypeSelector.adminissuecommentDescription": "Nechte se upozornit, když ostatní uživatelé komentují problémy.",
"components.NotificationTypeSelector.adminissuereopenedDescription": "Nechte se upozornit, když jsou problémy znovu otevřeny jinými uživateli.",
"components.IssueDetails.toaststatusupdatefailed": "Při aktualizaci stavu problému se něco pokazilo.",
"components.IssueDetails.IssueComment.postedby": "Zveřejněno {relativeTime} uživatelem {username}",
"components.IssueDetails.playonplex": "Přehrát na Plex",
"components.IssueDetails.play4konplex": "Přehrát ve 4K na Plex",
"components.IssueList.IssueItem.openeduserdate": "{date} od uživatele {user}",
"components.IssueList.IssueItem.openeduserdate": "{date} uživatelem {user}",
"components.IssueModal.CreateIssueModal.providedetail": "Uveďte prosím podrobné vysvětlení problému, na který jste narazili.",
"components.IssueModal.CreateIssueModal.reportissue": "Nahlásit problém",
"components.IssueModal.CreateIssueModal.toastFailedCreate": "Při odesílání problému se něco pokazilo.",
"components.IssueDetails.IssueComment.validationComment": "Musíte zadat zprávu",
"components.IssueDetails.IssueComment.areyousuredelete": "Opravdu chcete tento komentář smazat?",
"components.IssueDetails.IssueComment.areyousuredelete": "Opravdu chcete odstranit tento komentář?",
"components.IssueDetails.openinarr": "Otevřít v {arr}",
"components.IssueDetails.IssueComment.postedbyedited": "Zveřejněno {relativeTime} uživatelem {username} (upraveno)",
"components.IssueDetails.toasteditdescriptionsuccess": "Popis problému byl úspěšně upraven!",
"components.IssueDetails.deleteissueconfirm": "Opravdu chcete tento problém smazat?",
"components.IssueDetails.toasteditdescriptionsuccess": "Popis problému úspěšně upraven!",
"components.IssueDetails.deleteissueconfirm": "Opravdu chcete odstranit tento problém?",
"components.IssueDetails.toasteditdescriptionfailed": "Při úpravě popisu problému se něco pokazilo.",
"components.IssueDetails.toastissuedeletefailed": "Při mazání problému se něco pokazilo.",
"components.IssueDetails.toastissuedeletefailed": "Při odstraňování problému se něco pokazilo.",
"components.IssueModal.CreateIssueModal.extras": "Speciály",
"components.IssueList.showallissues": "Zobrazit všechny problémy",
"components.IssueModal.CreateIssueModal.toastSuccessCreate": "Zpráva o problému pro <strong>{title}</strong> byla úspěšně odeslána!",
"components.IssueModal.CreateIssueModal.toastSuccessCreate": "Zpráva o problému pro <strong>{title}</strong> úspěšně odeslána!",
"components.ManageSlideOver.mark4kavailable": "Označit jako dostupné ve 4K",
"components.NotificationTypeSelector.issuereopened": "Problém znovu otevřen",
"components.NotificationTypeSelector.userissuecreatedDescription": "Získejte upozornění, když ostatní uživatelé nahlásí problémy.",
@@ -646,9 +646,9 @@
"components.Settings.RadarrModal.enableSearch": "Povolení automatického vyhledávání",
"components.RequestModal.requestCancel": "Žádost o <strong>{title}</strong> zrušena.",
"components.Settings.Notifications.NotificationsPushover.userTokenTip": "Váš 30znakový <UsersGroupsLink>identifikátor uživatele nebo skupiny</UsersGroupsLink>",
"components.ManageSlideOver.pastdays": "Posledních {days, number} dní",
"components.ManageSlideOver.playedby": "Hraje",
"components.NotificationTypeSelector.adminissueresolvedDescription": "Získejte upozornění, když problémy vyřeší ostatní uživatelé.",
"components.ManageSlideOver.pastdays": "Posledních {days, number} {days, plural, one {den} few {dny} other {dní}}",
"components.ManageSlideOver.playedby": "Přehráno uživatelem",
"components.NotificationTypeSelector.adminissueresolvedDescription": "Nechte se upozornit, když jsou problémy vyřešeny jinými uživateli.",
"components.NotificationTypeSelector.issuecomment": "Komentář k problému",
"components.NotificationTypeSelector.issuecommentDescription": "Odesílat upozornění, když problémy obdrží nové komentáře.",
"components.NotificationTypeSelector.userissuecommentDescription": "Získejte upozornění na nové komentáře k problémům, které jste nahlásili.",
@@ -704,7 +704,7 @@
"components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "Nastavení oznámení Discordu úspěšně uloženo!",
"components.RequestButton.requestmore4k": "Vyžádat si více informací v rozlišení 4K",
"components.RequestModal.QuotaDisplay.quotaLink": "Souhrn limitů vašich požadavků si můžete prohlédnout na stránce vašeho <ProfileLink>profilu</ProfileLink>.",
"components.RequestModal.QuotaDisplay.requiredquota": "Abyste mohli podat žádost o tuto sérii, musíte mít alespoň <strong>{seasons}</strong> {seasons, plural, one {season request} other {season requests}} zbývajících pro podání žádosti o tuto sérii.",
"components.RequestModal.QuotaDisplay.requiredquota": "Abyste mohli zažádat o tento seriál, musíte mít alespoň <strong>{seasons}</strong> {seasons, plural, one {zbývající žádost o sezónu} few {zbývající žádosti o sezónu} other {zbývajících žádostí o sezónu}}.",
"components.RequestModal.requestfrom": "Žádost od {username} čeká na schválení.",
"components.RequestModal.requesterror": "Při odesílání žádosti se něco pokazilo.",
"components.Settings.Notifications.NotificationsLunaSea.webhookUrlTip": "Vaše adresa URL <LunaSeaLink>notification webhook</LunaSeaLink> pro uživatele nebo zařízení",
@@ -715,7 +715,7 @@
"components.Settings.SettingsAbout.uptodate": "Aktuální",
"components.Settings.SonarrModal.validationProfileRequired": "Je třeba vybrat profil kvality",
"components.Settings.currentlibrary": "Současná knihovna: {name}",
"components.IssueDetails.closeissueandcomment": "Zavřete komentářem",
"components.IssueDetails.closeissueandcomment": "Uzavřít s komentářem",
"components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingsfailed": "Nastavení oznámení Telegramu se nepodařilo uložit.",
"components.UserProfile.UserSettings.UserNotificationSettings.validationPgpPublicKey": "Musíte zadat platné ID uživatele",
"components.RequestModal.pendingapproval": "Vaše žádost čeká na schválení.",
@@ -969,49 +969,49 @@
"components.UserProfile.UserSettings.UserPasswordChange.noPasswordSet": "Tento uživatelský účet v současné době nemá nastavené heslo. Níže nastavte heslo, aby se tento účet mohl přihlašovat jako \"místní uživatel.\"",
"components.UserProfile.UserSettings.UserPasswordChange.noPasswordSetOwnAccount": "Váš účet v současné době nemá nastavené heslo. Níže nastavte heslo, abyste se mohli přihlásit jako \"místní uživatel\" pomocí své e-mailové adresy.",
"i18n.importing": "Importování…",
"components.IssueList.IssueItem.episodes": "{episodeCount, plural, zero {Epizod} one {Epizoda} other {Epizody}}",
"components.IssueList.IssueItem.seasons": "{seasonCount, plural, zero {Sezón} one {Sezóna} other {Sezóny}}",
"components.IssueList.IssueItem.episodes": "{episodeCount, plural, one {Epizoda} other {Epizody}}",
"components.IssueList.IssueItem.seasons": "{seasonCount, plural, one {Sezóna} other {Sezóny}}",
"components.TvDetails.productioncountries": "{countryCount, plural, one {Země} other {Země}} produkce",
"components.TvDetails.network": "{networkCount, plural, one {Síť} other {Sítě}}",
"components.RequestModal.QuotaDisplay.seasonlimit": "{limit, plural, one {sezóna} other {sezóny}}",
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {přehrání} other {přehrání}}",
"components.MovieDetails.productioncountries": "Produkční {countryCount, plural, one {Země} other {Země}}",
"components.TvDetails.network": "{networkCount, plural, one {Stanice} other {Stanice}}",
"components.RequestModal.QuotaDisplay.seasonlimit": "{limit, plural, one {sezóna} few {sezóny} other {sezón}}",
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {přehrání} few {přehrání} other {přehrání}}",
"components.MovieDetails.productioncountries": "{countryCount, plural, one {Země} other {Země}} produkce",
"components.MovieDetails.studio": "{studioCount, plural, one {Studio} other {Studia}}",
"components.QuotaSelector.days": "{count, plural, zero {dní} one {den} other {dny}}",
"components.TvDetails.seasons": "{seasonCount, plural, one {# Sezóna} other {# Sezóny}}",
"components.QuotaSelector.days": "{count, plural, one {den} few {dny} other {dní}}",
"components.TvDetails.seasons": "{seasonCount, plural, one {# sezóna} few {# sezóny} other {# sezón}}",
"components.RequestCard.seasons": "{seasonCount, plural, one {Sezóna} other {Sezóny}}",
"components.RequestModal.QuotaDisplay.requiredquotaUser": "Aby mohl tento uživatel odeslat žádost o seriál, musí mít zbývajících alespoň <strong>{seasons}</strong> {seasons, plural, one {požadavku pro sezónu} other {požadavky pro sezóny}}.",
"components.RequestModal.requestmovies4k": "Požádat {count} {count, plural, one {Movie} other {Movies}} v 4K",
"components.RequestModal.QuotaDisplay.requiredquotaUser": "Aby mohl tento uživatel zažádat o tento seriál, musí mít alespoň <strong>{seasons}</strong> {seasons, plural, one {zbývající žádost o sezónu} few {zbývající žádosti o sezónu} other {zbývajících žádostí o sezónu}}.",
"components.RequestModal.requestmovies4k": "Zažádat o {count} {count, plural, one {film} few {filmy} other {filmů}} ve 4K",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "{jobScheduleHours, plural, one {Každou hodinu} few {Každé {jobScheduleHours} hodiny} other {Každých {jobScheduleHours} hodin}}",
"components.RequestBlock.seasons": "{seasonCount, plural, zero {Sezón} one {Sezóna} other {Sezóny}}",
"components.RequestButton.approve4krequests": "Schválit {requestCount, plural, one {4K žádost} other {{requestCount} 4K žádosti}}",
"components.RequestBlock.seasons": "{seasonCount, plural, one {Sezóna} other {Sezóny}}",
"components.RequestButton.approve4krequests": "Schválit {requestCount, plural, one {4K žádost} few {{requestCount} 4K žádosti} other {{requestCount} 4K žádostí}}",
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Sezóna} other {Sezóny}}",
"components.RequestModal.QuotaDisplay.allowedRequestsUser": "Tento uživatel může požádat o <strong>{limit}</strong> {type} každých <strong>{days}</strong> dní.",
"components.RequestModal.QuotaDisplay.movielimit": "{limit, plural, one {film} other {filmy}}",
"components.RequestModal.QuotaDisplay.requestsremaining": "{remaining, plural, =0 {Žiadne} other {<strong>#</strong>}} {type} {remaining, plural, one {požadavka} other {požadavky}} zbývající",
"components.RequestModal.requestseasons": "Požádat {seasonCount} {seasonCount, plural, one {sezónu} other {sezóny}}",
"components.RequestModal.requestseasons4k": "Požádat {seasonCount} {seasonCount, plural, one {sezónu} other {sezóny}} v 4K",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> Plex {userCount, plural, one {uživatel úspěšně importován} other {uživatelů úspěšně importováno}}!",
"components.RequestModal.QuotaDisplay.movielimit": "{limit, plural, one {film} few {filmy} other {filmů}}",
"components.RequestModal.QuotaDisplay.requestsremaining": "{remaining, plural, =0 {Žádné} other {<strong>#</strong>}} {type} {remaining, plural, one {zbývající žádost} few {zbývající žádosti} other {zbývajících žádostí}}",
"components.RequestModal.requestseasons": "Zažádat o {seasonCount} {seasonCount, plural, one {sezónu} few {sezóny} other {sezón}}",
"components.RequestModal.requestseasons4k": "Zažádat o {seasonCount} {seasonCount, plural, one {sezónu} few {sezóny} other {sezón}} ve 4K",
"components.UserList.importedfromplex": "Úspěšně {userCount, plural, one {importován} few {importováni} other {importováno}} <strong>{userCount}</strong> {userCount, plural, one {uživatel} few {uživatelé} other {uživatelů}} Plex!",
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguageTip": "Filtrovat obsah podle původního jazyka",
"components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessTokenTip": "Vytvořte token z <PushbulletSettingsLink>Nastavení účtu</PushbulletSettingsLink>",
"components.QuotaSelector.movies": "{count, plural, zero {filmů} one {film} other {filmy}}",
"components.QuotaSelector.seasons": "{seasonCount, plural, zero {sezón} one {sezóna} other {sezóny}}",
"components.QuotaSelector.movies": "{count, plural, one {film} few {filmy} other {filmů}}",
"components.QuotaSelector.seasons": "{seasonCount, plural, one {sezóna} few {sezóny} other {sezón}}",
"components.QuotaSelector.tvRequests": "{quotaLimit} <quotaUnits>{seasons} za {quotaDays} {days}</quotaUnits>",
"components.RequestButton.approverequests": "Schválit {requestCount, plural, one {Žádost} other {{requestCount} Žádosti}}",
"components.RequestButton.decline4krequests": "Odmítnout {requestCount, plural, one {4K žádost} other {{requestCount} 4K žádosti}}",
"components.RequestButton.declinerequests": "Odmítnout {requestCount, plural, one {Žádost} other {{requestCount} Žádosti}}",
"components.RequestModal.requestmovies": "Požádat {count} {count, plural, one {film} other {filmy}}",
"components.RequestButton.approverequests": "Schválit {requestCount, plural, one {žádost} few {{requestCount} žádosti} other {{requestCount} žádostí}}",
"components.RequestButton.decline4krequests": "Odmítnout {requestCount, plural, one {4K žádost} few {{requestCount} 4K žádosti} other {{requestCount} 4K žádostí}}",
"components.RequestButton.declinerequests": "Odmítnout {requestCount, plural, one {žádost} few {{requestCount} žádosti} other {{requestCount} žádostí}}",
"components.RequestModal.requestmovies": "Zažádat o {count} {count, plural, one {film} few {filmy} other {filmů}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "{jobScheduleMinutes, plural, one {Každou minutu} few {Každé {jobScheduleMinutes} minuty} other {Každých {jobScheduleMinutes} minut}}",
"components.MovieDetails.digitalrelease": "Digitální vydání",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Váš Plex Watchlist",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Váš seznam ke zhlédnutí Plex",
"components.MovieDetails.physicalrelease": "Fyzické vydání",
"components.MovieDetails.managemovie": "Spravovat film",
"components.AirDateBadge.airsrelative": "Vysíla se {relativeTime}",
"components.AirDateBadge.airsrelative": "Vysílá se {relativeTime}",
"components.Layout.UserDropdown.MiniQuotaDisplay.movierequests": "Žádosti o filmy",
"components.MovieDetails.rtaudiencescore": "Skóre publika Rotten Tomatoes",
"components.MovieDetails.rtcriticsscore": "Rotten Tomatoes Tomatometer",
"components.Layout.UserDropdown.MiniQuotaDisplay.seriesrequests": "Žádosti o seriály",
"components.AirDateBadge.airedrelative": "Odvysílano {relativeTime}",
"components.AirDateBadge.airedrelative": "Vysíláno {relativeTime}",
"components.Layout.UserDropdown.requests": "Žádosti",
"components.MovieDetails.reportissue": "Nahlásit problém",
"components.StatusChecker.restartRequired": "Je vyžadován restart serveru",
@@ -1064,15 +1064,15 @@
"components.RequestModal.requestmovietitle": "Vyžádat si film",
"components.RequestModal.requestseries4ktitle": "Žádost o sérii ve 4K",
"components.RequestModal.requestseriestitle": "Vyžádat sérii",
"components.TvDetails.episodeCount": "{episodeCount, plural, one {# Epizoda} other {# Epizody}}",
"components.NotificationTypeSelector.mediaautorequested": "Žádost byla odeslána automaticky",
"components.NotificationTypeSelector.mediaautorequestedDescription": "Získejte upozornění, když jsou automaticky odeslány nové požadavky na média pro položky na vašem Plex Watchlistu.",
"components.TvDetails.episodeCount": "{episodeCount, plural, one {# epizoda} few {# epizody} other {# epizod}}",
"components.NotificationTypeSelector.mediaautorequested": "Žádost byla automaticky odeslána",
"components.NotificationTypeSelector.mediaautorequestedDescription": "Nechte se upozornit, když jsou nové žádosti o média automaticky odeslány pro položky na vašem Seznamu ke zhlédnutí Plex.",
"components.PermissionEdit.viewwatchlistsDescription": "Udělte oprávnění k zobrazení Plex Watchlistu ostatních uživatelů.",
"components.TvDetails.Season.somethingwentwrong": "Při načítání údajů o sezóně se něco pokazilo.",
"i18n.restartRequired": "Je vyžadován restart",
"components.Discover.plexwatchlist": "Váš Plex Watchlist",
"components.MovieDetails.theatricalrelease": "Dostupné v kinách",
"components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist",
"components.Discover.plexwatchlist": "Váš seznam ke zhlédnutí Plex",
"components.MovieDetails.theatricalrelease": "Uvedení v kinech",
"components.Discover.DiscoverWatchlist.watchlist": "Seznam ke zhlédnutí Plex",
"components.TvDetails.reportissue": "Nahlásit problém",
"components.MovieDetails.tmdbuserscore": "Uživatelské skóre TMDB",
"components.TvDetails.seasonstitle": "Sezóny",
@@ -1081,12 +1081,155 @@
"components.TvDetails.rtaudiencescore": "Rotten Tomatoes Audience Skóre",
"components.TvDetails.tmdbuserscore": "Uživatelské skóre TMDB",
"components.TvDetails.status4k": "4K {status}",
"components.Discover.emptywatchlist": "Média přidaná do vašeho <PlexWatchlistSupportLink>Plex Watchlistu</PlexWatchlistSupportLink> se zobrazí zde.",
"components.Discover.emptywatchlist": "Zde se objeví média přidaná do vašeho <PlexWatchlistSupportLink>seznamu ke zhlédnutí Plex</PlexWatchlistSupportLink>.",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmoviestip": "Automaticky vyžádat filmy na vašem <PlexWatchlistSupportLink>Plex Watchlistu</PlexWatchlistSupportLink>",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseriestip": "Automaticky vyžádejte sérii na vašem <PlexWatchlistSupportLink>Plex Watchlistu</PlexWatchlistSupportLink>",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseries": "Automaticky vyžádat sérii",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmovies": "Automaticky vyžádat filmy",
"components.UserProfile.plexwatchlist": "Plex Watchlist",
"components.UserProfile.emptywatchlist": "Zde se zobrazí média přidaná do vašeho <PlexWatchlistSupportLink>Plex Watchlistu</PlexWatchlistSupportLink>.",
"components.Settings.SettingsJobsCache.editJobScheduleCurrent": "Aktuální frekvence"
"components.Settings.SettingsJobsCache.editJobScheduleCurrent": "Aktuální frekvence",
"components.Discover.DiscoverMovieKeyword.keywordMovies": "Filmy: {keywordTitle}",
"components.Discover.networks": "Stanice",
"components.Discover.moviegenres": "Žánry filmů",
"components.Discover.tmdbmoviegenre": "Žánr filmu TMDB",
"components.Discover.tmdbnetwork": "Stanice TMDB",
"components.Discover.CreateSlider.providetmdbsearch": "Zadejte vyhledávací dotaz",
"components.Discover.CreateSlider.starttyping": "Začínáte psát pro hledání.",
"components.Discover.DiscoverMovies.activefilters": "{count, plural, one {# aktivní filtr} few {# aktivní filtry} other {# aktivních filtrů}}",
"components.Discover.FilterSlideover.streamingservices": "Streamovací služby",
"components.Discover.CreateSlider.addsuccess": "Vytvořen nový posuvník a uloženo nastavení přizpůsobení objevování.",
"components.Discover.createnewslider": "Vytvořit nový posuvník",
"components.Discover.customizediscover": "Přizpůsobit objevování",
"components.Discover.CreateSlider.validationDatarequired": "Musíte zadat hodnotu dat.",
"components.Discover.CreateSlider.validationTitlerequired": "Musíte zadat název.",
"components.Discover.DiscoverSliderEdit.deletefail": "Nepodařilo se odstranit posuvník.",
"components.Discover.DiscoverSliderEdit.deletesuccess": "Posuvník úspěšně odstraněn.",
"components.Discover.DiscoverSliderEdit.enable": "Přepnout viditelnost",
"components.Discover.DiscoverSliderEdit.remove": "Odebrat",
"components.Discover.DiscoverTvKeyword.keywordSeries": "Seriály: {keywordTitle}",
"components.Discover.PlexWatchlistSlider.emptywatchlist": "Zde se objeví média přidaná do vašeho <PlexWatchlistSupportLink>seznamu ke shlédnutí Plex</PlexWatchlistSupportLink>.",
"components.Discover.stopediting": "Zastavit úpravy",
"components.Discover.studios": "Studia",
"components.Discover.tmdbmoviekeyword": "Klíčové slovo filmu TMDB",
"components.Discover.tmdbtvkeyword": "Klíčové slovo seriálu TMDB",
"components.Discover.tvgenres": "Žánry seriálu",
"components.Discover.updatefailed": "Při aktualizaci nastavení přizpůsobení objevování se něco pokazilo.",
"components.Discover.tmdbtvgenre": "Žánr seriálu TMDB",
"components.Discover.updatesuccess": "Nastavení přizpůsobení objevování aktualizováno.",
"components.Discover.DiscoverMovies.discovermovies": "Filmy",
"components.Discover.DiscoverMovies.sortPopularityAsc": "Oblíbenost vzestupně",
"components.Discover.DiscoverMovies.sortPopularityDesc": "Oblíbenost sestupně",
"components.Discover.DiscoverMovies.sortReleaseDateAsc": "Datum vydání vzestupně",
"components.Discover.DiscoverMovies.sortReleaseDateDesc": "Datum vydání sestupně",
"components.Discover.DiscoverMovies.sortTitleAsc": "Název (A-Z) vzestupně",
"components.Discover.DiscoverMovies.sortTitleDesc": "Název (A-Z) sestupně",
"components.Discover.DiscoverMovies.sortTmdbRatingAsc": "Hodnocení TMDB vzestupně",
"components.Discover.DiscoverMovies.sortTmdbRatingDesc": "Hodnocení TMDB sestupně",
"components.Discover.DiscoverTv.discovertv": "Seriály",
"components.Discover.DiscoverTv.sortFirstAirDateAsc": "Datum prvního vysílání vzestupně",
"components.Discover.DiscoverTv.activefilters": "{count, plural, one {# aktivní filtr} few {# aktivní filtry} other {# aktivních filtrů}}",
"components.Discover.DiscoverTv.sortFirstAirDateDesc": "Datum prvního vysílání sestupně",
"components.Discover.DiscoverTv.sortPopularityAsc": "Oblíbenost vzestupně",
"components.Discover.DiscoverTv.sortPopularityDesc": "Oblíbenost sestupně",
"components.Discover.DiscoverTv.sortTitleAsc": "Název (A-Z) vzestupně",
"components.Discover.DiscoverTv.sortTitleDesc": "Název (A-Z) sestupně",
"components.Discover.DiscoverTv.sortTmdbRatingAsc": "Hodnocení TMDB vzestupně",
"components.Discover.DiscoverTv.sortTmdbRatingDesc": "Hodnocení TMDB sestupně",
"components.Discover.FilterSlideover.clearfilters": "Vymazat aktivní filtry",
"components.Discover.FilterSlideover.filters": "Filtry",
"components.Discover.FilterSlideover.firstAirDate": "Datum prvního vysílání",
"components.Discover.FilterSlideover.from": "Od",
"components.Discover.FilterSlideover.activefilters": "{count, plural, one {# aktivní filtr} few {# aktivní filtry} other {# aktivních filtrů}}",
"components.Discover.FilterSlideover.genres": "Žánry",
"components.Discover.FilterSlideover.keywords": "Klíčová slova",
"components.Discover.FilterSlideover.originalLanguage": "Původní jazyk",
"components.Discover.FilterSlideover.ratingText": "Hodnocení mezi {minValue} a {maxValue}",
"components.Discover.FilterSlideover.releaseDate": "Datum vydání",
"components.Discover.FilterSlideover.runtime": "Délka",
"components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minut délky",
"components.Discover.FilterSlideover.studio": "Studio",
"components.Discover.FilterSlideover.tmdbuserscore": "Uživatelské hodnocení TMDB",
"components.Discover.FilterSlideover.to": "Do",
"components.Discover.PlexWatchlistSlider.plexwatchlist": "Váš seznam ke zhlédnutí Plex",
"components.DownloadBlock.formattedTitle": "{title}: Sezóna {seasonNumber} Epizoda {episodeNumber}",
"components.Discover.RecentlyAddedSlider.recentlyAdded": "Nedávno přidané",
"components.Discover.resetfailed": "Při obnovení nastavení přizpůsobení objevování se něco pokazilo.",
"components.Discover.resetsuccess": "Nastavení přizpůsobení objevování úspěšně obnoveno.",
"components.Discover.resettodefault": "Obnovit na výchozí",
"components.Discover.resetwarning": "Obnovit všechny posuvníky na výchozí hodnoty. Toto také odstraní všechny vlastní posuvníky!",
"components.Discover.tmdbsearch": "Hledání TMDB",
"components.Discover.tmdbstudio": "Studio TMDB",
"components.Layout.Sidebar.browsemovies": "Filmy",
"components.Layout.Sidebar.browsetv": "Seriály",
"components.Discover.CreateSlider.addSlider": "Přidat posuvník",
"components.Discover.CreateSlider.addcustomslider": "Vytvořit vlastní posuvník",
"components.Discover.CreateSlider.addfail": "Nepodařilo se vytvořit nový posuvník.",
"components.Discover.CreateSlider.editSlider": "Upravit posuvník",
"components.Discover.CreateSlider.editfail": "Nepodařilo se upravit posuvník.",
"components.Discover.CreateSlider.editsuccess": "Upraven posuvník a uloženo nastavení přizpůsobení objevování.",
"components.Discover.CreateSlider.needresults": "Musíte mít alespoň 1 výsledek.",
"components.Discover.CreateSlider.nooptions": "Žádné výsledky.",
"components.Discover.CreateSlider.providetmdbgenreid": "Zadejte ID žánru TMDB",
"components.Discover.CreateSlider.providetmdbkeywordid": "Zadejte ID klíčového slova TMDB",
"components.Discover.CreateSlider.providetmdbnetwork": "Zadejte ID stanice TMDB",
"components.Discover.CreateSlider.providetmdbstudio": "Zadejte ID studia TMDB",
"components.Discover.CreateSlider.searchGenres": "Hledat žánry…",
"components.Discover.CreateSlider.searchKeywords": "Hledat klíčová slova…",
"components.Discover.CreateSlider.searchStudios": "Hledat studia…",
"components.Discover.CreateSlider.slidernameplaceholder": "Název posuvníku",
"components.Settings.SettingsMain.toastApiKeySuccess": "Nový klíč API byl úspěšně vygenerován!",
"components.TvDetails.Season.noepisodes": "Seznam epizod není k dispozici.",
"components.Settings.SettingsJobsCache.imagecachesize": "Celková velikost mezipaměti",
"components.Settings.SettingsMain.validationApplicationUrl": "Musíte zadat platnou adresu URL",
"components.Selector.starttyping": "Začínáte psát a hledáte.",
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "Adresa URL nesmí končit koncovým lomítkem",
"components.Selector.searchStudios": "Hledat studia…",
"components.RequestCard.unknowntitle": "Neznámý název",
"components.RequestList.RequestItem.unknowntitle": "Neznámý název",
"components.Selector.searchGenres": "Vyberte žánry…",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Každou {jobScheduleSeconds, plural, one {sekundu} other {{jobScheduleSeconds} sekund}}",
"components.Settings.SettingsMain.apikey": "API klíč",
"components.Settings.SettingsMain.applicationTitle": "Název aplikace",
"components.Settings.SettingsMain.applicationurl": "Adresa URL aplikace",
"components.Settings.SettingsMain.cacheImages": "Povolení ukládání obrázků do mezipaměti",
"components.Settings.SettingsMain.cacheImagesTip": "Ukládání obrázků z externích zdrojů do mezipaměti (vyžaduje značné množství místa na disku)",
"components.Settings.SettingsMain.csrfProtection": "Povolení ochrany CSRF",
"components.Settings.SettingsMain.csrfProtectionHoverTip": "Toto nastavení NEPOVOLUJTE, pokud nerozumíte tomu, co děláte!",
"components.Settings.SettingsMain.csrfProtectionTip": "Nastavení externího přístupu k rozhraní API pouze pro čtení (vyžaduje protokol HTTPS)",
"components.Settings.SettingsMain.general": "Obecné",
"components.Settings.SettingsMain.generalsettings": "Obecná nastavení",
"components.Settings.SettingsMain.generalsettingsDescription": "Konfigurace globálních a výchozích nastavení pro Overseerr.",
"components.Settings.SettingsMain.hideAvailable": "Skrýt dostupná média",
"components.Settings.SettingsMain.locale": "Jazyk zobrazení",
"components.Settings.SettingsMain.originallanguage": "Objevte jazyk",
"components.Settings.SettingsMain.originallanguageTip": "Filtrování obsahu podle původního jazyka",
"components.Settings.SettingsMain.partialRequestsEnabled": "Povolení požadavků na částečné série",
"components.Settings.SettingsMain.region": "Objevte region",
"components.Settings.SettingsMain.regionTip": "Filtrování obsahu podle regionální dostupnosti",
"components.Settings.SettingsMain.trustProxyTip": "Umožnit Overseerru správně registrovat klientské IP adresy za proxy serverem",
"components.Settings.SettingsJobsCache.imagecachecount": "Obrázky v mezipaměti",
"components.Settings.SettingsJobsCache.imagecache": "Vyrovnávací paměť obrázků",
"components.Settings.SettingsJobsCache.imagecacheDescription": "Pokud je tato funkce povolena v nastavení, bude služba Overseerr proxy serverem a ukládat do mezipaměti obrázky z předem nakonfigurovaných externích zdrojů. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.",
"components.Settings.SettingsMain.validationApplicationTitle": "Musíte uvést název žádosti",
"components.Settings.SettingsMain.toastApiKeyFailure": "Při generování nového klíče API se něco pokazilo.",
"components.Settings.SettingsMain.toastSettingsFailure": "Při ukládání nastavení se něco pokazilo.",
"components.Settings.SettingsMain.toastSettingsSuccess": "Nastavení úspěšně uloženo!",
"components.Settings.SettingsMain.trustProxy": "Povolení podpory proxy serveru",
"components.Settings.SettingsJobsCache.image-cache-cleanup": "Čištění mezipaměti obrázků",
"components.StatusBadge.seasonepisodenumber": "S{seasonNumber}E{episodeNumber}",
"components.Selector.searchKeywords": "Klíčová slova pro vyhledávání…",
"components.Selector.showless": "Zobrazit méně",
"components.Selector.showmore": "Zobrazit více",
"components.Selector.nooptions": "Žádné výsledky.",
"components.Discover.FilterSlideover.voteCount": "Počet hlasů mezi {minValue} a {maxValue}",
"components.Settings.RadarrModal.tagRequestsInfo": "Automatické přidání dodatočné značky s ID uživatele a zobrazovaným jménem žadatele",
"components.Settings.SonarrModal.tagRequests": "Požadavky na štítek",
"i18n.collection": "Kolekce",
"components.Settings.RadarrModal.tagRequests": "Požadavky na štítek",
"components.Settings.SettingsJobsCache.availability-sync": "Synchronizace dostupnosti médií",
"components.Discover.FilterSlideover.tmdbuservotecount": "Počet hlasů uživatelů TMDB",
"components.Discover.tmdbmoviestreamingservices": "Filmové streamovací služby TMDB",
"components.Settings.SonarrModal.tagRequestsInfo": "Automatické přidání dodatočné značky s ID uživatele a zobrazovaným jménem žadatele",
"components.Discover.tmdbtvstreamingservices": "Televizní streamovací služby TMDB"
}

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