Compare commits

..

92 Commits

Author SHA1 Message Date
sct
d6b4cfdd98 build: try to fix sqlite for arm 2023-11-06 10:21:37 +00:00
Ryan Cohen
e297d25603 build: add global node-gyp for arm (#3676) 2023-11-06 18:56:57 +09:00
Ryan Cohen
89287af096 build: use node 18 (#3675) 2023-11-06 18:48:02 +09:00
Ryan Cohen
3a593d9d76 build: update docker ubuntu images to 22.04 (#3671) 2023-11-05 22:40:03 +09:00
Ryan Cohen
a45fc86032 build: do not link python for arm (#3670) 2023-11-05 20:43:58 +09:00
Ryan Cohen
59dabed380 build: update node to 20.9 (#3668) 2023-11-05 19:18:21 +09:00
Ryan Cohen
b40ba07a4d fix: request watchlist items sequentially to prevent bypassing quota (#3667) 2023-11-05 10:39:22 +09:00
allcontributors[bot]
246887efa1 docs: add RemiRigal as a contributor for code (#3653) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-10-19 17:55:24 +09:00
Brandon Cohen
28a2c50495 fix: resolved issue with region selector and all regions value (#3652) 2023-10-19 16:46:54 +09:00
RemiRigal
c84ca43074 feat: update SameSite policy of session cookie to Lax (#3650)
* update session cookie samesite policy to lax

* set cookie samesite policy based on csrf protection setting
2023-10-18 15:05:22 +00:00
TheCatLady
e2771a3011 chore: specify files/directories to exclude from git archives (#2184)
Co-authored-by: Danshil Kokil Mungur <me@danshilm.com>
2023-10-16 17:02:01 +00:00
TheCatLady
3ea5076053 feat(notif): add Pushover sound options (#2403)
Co-authored-by: Danshil Kokil Mungur <me@danshilm.com>
2023-10-15 23:05:36 +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
Brandon Cohen
3e43586acc fix(ui): air date will use UTC for timezone (#3297) 2023-01-29 16:43:54 +09:00
Ryan Cohen
7040da1334 fix(ui): show 5 icons when possible on mobile menu (#3298) 2023-01-29 16:27:09 +09:00
Ryan Cohen
9d10e6a88c fix(ui): style range thumbs correctly for firefox (#3294) 2023-01-29 13:27:33 +09:00
Brandon Cohen
8942eb8b7c fix: pass in library type when scanning recently added items (#3287) 2023-01-27 21:53:46 +09:00
Danshil Kokil Mungur
51b05cd8fb fix(build): update usage of publish snap action (#3272)
* fix(build): use env variable to login with snapcraft 7

* refactor(build): replace deprecated set-output command in GHA

* fix(build): use correct environment variable for output

* style(build): run prettier
2023-01-27 09:48:40 +09:00
Brandon Cohen
374c78c989 fix(ui): series first air date sorting (#3283) 2023-01-26 22:22:38 +00:00
Brandon Cohen
507693881b fix: multiple genre filtering now works (#3282) 2023-01-26 14:10:02 -08:00
Ryan Cohen
f4a22dc437 fix: correctly check mobile menu permissions (#3271) 2023-01-25 18:49:01 +09:00
Ryan Cohen
5d1c6f7065 fix: create shared class to add bottom spacing (#3269) 2023-01-24 19:30:38 +09:00
Ryan Cohen
3db010b9ea fix: correct issue detail bottom padding on mobile displays (#3268) 2023-01-24 14:54:58 +09:00
TheCatLady
fd219717c0 fix: issues with issues (#3267)
* fix: issues with issues

* fix: don't notify on user closing/reopening own issue

* fix: only show close/reopen buttons for OP and admins
2023-01-24 02:58:56 +00:00
Brandon Cohen
d328485161 fix: arrow icons were misplaced on mobile in slider edit (#3260) 2023-01-20 14:46:29 +00:00
Brandon Cohen
da00d454e1 feat: discover slider edit arrow buttons for reordering (#3259) 2023-01-20 06:20:05 +00:00
Danshil Kokil Mungur
d7bfc73727 docs(installation): add PORT env variable to example commands (#3254) [skip ci]
* docs(installation): add PORT env variable to Docker CLI & Docker Compose examples

* docs(installation): fix typo

* docs(installation): clarify hint about named volumes for windows installation example
2023-01-20 02:39:24 +09:00
Weblate (bot)
5940ff7f5f feat: translations update from Hosted Weblate (#3218)
* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* 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% (1217 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.4% (1215 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.4% (1215 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.2% (1213 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 98.6% (1203 of 1219 strings)

feat(lang): translated using Weblate (French)

Currently translated at 92.6% (1130 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (French)

Currently translated at 96.0% (1132 of 1179 strings)

feat(lang): translated using Weblate (French)

Currently translated at 95.9% (1131 of 1179 strings)

Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mathieu <math_du_88@yahoo.fr>
Co-authored-by: Sulli <susu.leduc@gmail.com>
Co-authored-by: Uruk <uruknarb20@gmail.com>
Co-authored-by: slundi <slundi@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 85.5% (1045 of 1222 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Polish)

Currently translated at 93.6% (1144 of 1222 strings)

feat(lang): translated using Weblate (Polish)

Currently translated at 90.6% (1105 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 100.0% (1219 of 1219 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 96.9% (1182 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1179 of 1179 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 94.0% (1105 of 1175 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 100.0% (1219 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Dutch)

Currently translated at 95.5% (1126 of 1179 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 93.8% (1107 of 1179 strings)

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/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 94.6% (1154 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1175 of 1175 strings)

Co-authored-by: Ben <ben.david.wallner@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Norwegian Bokmål)

Currently translated at 94.7% (1113 of 1175 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 11.8% (145 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 9.4% (115 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 9.1% (112 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 9.1% (112 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 2.7% (33 of 1222 strings)

Co-authored-by: Gaurang Goel <gaurang7goel+hosted@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ishan Jindal <jindal25ishan@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hi/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Albanian)

Currently translated at 97.1% (1142 of 1175 strings)

Co-authored-by: Denis Çerri <deniscerri3@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sq/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Mathieu <math_du_88@yahoo.fr>
Co-authored-by: Sulli <susu.leduc@gmail.com>
Co-authored-by: Uruk <uruknarb20@gmail.com>
Co-authored-by: slundi <slundi@gmail.com>
Co-authored-by: Angel <adelpozoman@gmail.com>
Co-authored-by: Patryk <byakurau1@gmail.com>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Ben <ben.david.wallner@gmail.com>
Co-authored-by: exentler <gurandsrud@gmail.com>
Co-authored-by: Gaurang Goel <gaurang7goel+hosted@gmail.com>
Co-authored-by: Ishan Jindal <jindal25ishan@gmail.com>
Co-authored-by: Denis Çerri <deniscerri3@gmail.com>
2023-01-19 21:53:44 +09:00
Ryan Cohen
fcbca1722f feat: new mobile menu (#3251) 2023-01-17 18:00:15 +09:00
Ryan Cohen
19370f856c chore: add holopin config (#3250) [skip ci] 2023-01-17 10:36:00 +09:00
154 changed files with 10251 additions and 2800 deletions

View File

@@ -773,6 +773,141 @@
"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"
]
},
{
"login": "RemiRigal",
"name": "RemiRigal",
"avatar_url": "https://avatars.githubusercontent.com/u/19256051?v=4",
"profile": "https://github.com/RemiRigal",
"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 +917,6 @@
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": false,
"commitConvention": "angular"
"commitConvention": "angular",
"commitType": "docs"
}

21
.gitattributes vendored
View File

@@ -24,3 +24,24 @@
*.woff binary
*.pyc binary
*.pdf binary
#
## Theses files/directories should be excluded from git archives
#
.husky export-ignore
.vscode export-ignore
docs export-ignore
.git* export-ignore
*ignore export-ignore
*.md export-ignore
.all-contributorsrc export-ignore
.editorconfig export-ignore
Dockerfile.local export-ignore
docker-compose.yml export-ignore
stylelint.config.js export-ignore
public/os_logo_filled.png export-ignore
public/preview.jpg export-ignore

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

5
.github/holopin.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
organization: overseerr
defaultSticker: clcyagj1j329008l468ya8pu2
stickers:
- id: clcyagj1j329008l468ya8pu2
alias: overseerr-contributor

View File

@@ -12,8 +12,8 @@ jobs:
test:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-20.04
container: node:16.17-alpine
runs-on: ubuntu-22.04
container: node:18.18-alpine
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -31,7 +31,7 @@ jobs:
build_and_push:
name: Build & Publish Docker Images
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -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,21 +64,12 @@ 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
needs: build_and_push
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
@@ -94,9 +78,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
echo "status=failure" >> $GITHUB_OUTPUT
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

View File

@@ -8,13 +8,13 @@ on:
jobs:
build_and_push:
name: Build & Publish Docker Preview Images
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Get the version
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
@@ -35,7 +35,7 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
platforms: linux/amd64,linux/arm64
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}

View File

@@ -8,7 +8,7 @@ on:
jobs:
semantic-release:
name: Tag and release latest version
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -45,7 +45,7 @@ jobs:
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: semantic-release
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
@@ -67,9 +67,9 @@ jobs:
run: |
git fetch --prune --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo ::set-output name=RELEASE::stable
echo "RELEASE=stable" >> $GITHUB_OUTPUT
else
echo ::set-output name=RELEASE::edge
echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v2
@@ -91,8 +91,9 @@ jobs:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
with:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
@@ -100,7 +101,7 @@ jobs:
name: Send Discord Notification
needs: semantic-release
if: always()
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
@@ -109,9 +110,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
echo "status=failure" >> $GITHUB_OUTPUT
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

View File

@@ -8,7 +8,7 @@ on:
jobs:
jobs:
name: Job Check
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Cancel Previous Runs
@@ -19,7 +19,7 @@ jobs:
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: jobs
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
@@ -35,9 +35,9 @@ jobs:
run: |
git fetch --prune --unshallow --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo ::set-output name=RELEASE::stable
echo "RELEASE=stable" >> $GITHUB_OUTPUT
else
echo ::set-output name=RELEASE::edge
echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v2
@@ -57,8 +57,9 @@ jobs:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
with:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
@@ -66,7 +67,7 @@ jobs:
name: Send Discord Notification
needs: build-snap
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
@@ -75,9 +76,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
echo "status=failure" >> $GITHUB_OUTPUT
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

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

@@ -1,17 +1,18 @@
FROM node:16.17-alpine AS BUILD_IMAGE
FROM node:18.18-alpine AS BUILD_IMAGE
WORKDIR /app
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
RUN \
case "${TARGETPLATFORM}" in \
'linux/arm64' | 'linux/arm/v7') \
apk add --no-cache python3 make g++ && \
ln -s /usr/bin/python3 /usr/bin/python \
;; \
esac
# RUN \
# case "${TARGETPLATFORM}" in \
# 'linux/arm64' | 'linux/arm/v7') \
# apk update && \
# apk add --no-cache python3 make g++ gcc libc6-compat bash && \
# yarn global add node-gyp \
# ;; \
# esac
COPY package.json yarn.lock ./
RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
@@ -33,7 +34,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:16.17-alpine
FROM node:18.18-alpine
WORKDIR /app

View File

@@ -1,4 +1,4 @@
FROM node:16.17-alpine
FROM node:18.18-alpine
COPY . /app
WORKDIR /app

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-99-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@@ -182,6 +182,27 @@ 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>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RemiRigal"><img src="https://avatars.githubusercontent.com/u/19256051?v=4?s=100" width="100px;" alt="RemiRigal"/><br /><sub><b>RemiRigal</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=RemiRigal" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -3,147 +3,147 @@
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
"main": {
"apiKey": "testkey",
"applicationTitle": "Overseerr",
"applicationUrl": "",
"csrfProtection": false,
"cacheImages": false,
"defaultPermissions": 32,
"defaultQuotas": {
"movie": {},
"tv": {}
},
"hideAvailable": false,
"localLogin": true,
"newPlexLogin": true,
"region": "",
"originalLanguage": "",
"trustProxy": false,
"partialRequestsEnabled": true,
"locale": "en"
"apiKey": "testkey",
"applicationTitle": "Overseerr",
"applicationUrl": "",
"csrfProtection": false,
"cacheImages": false,
"defaultPermissions": 32,
"defaultQuotas": {
"movie": {},
"tv": {}
},
"hideAvailable": false,
"localLogin": true,
"newPlexLogin": true,
"region": "",
"originalLanguage": "",
"trustProxy": false,
"partialRequestsEnabled": true,
"locale": "en"
},
"plex": {
"name": "Seerr",
"ip": "192.168.1.1",
"port": 32400,
"useSsl": false,
"libraries": [
{
"id": "1",
"name": "Movies",
"enabled": true,
"type": "movie"
}
],
"machineId": "test"
"name": "Seerr",
"ip": "192.168.1.1",
"port": 32400,
"useSsl": false,
"libraries": [
{
"id": "1",
"name": "Movies",
"enabled": true,
"type": "movie"
}
],
"machineId": "test"
},
"tautulli": {},
"radarr": [],
"sonarr": [],
"public": {
"initialized": true
"initialized": true
},
"notifications": {
"agents": {
"email": {
"enabled": false,
"options": {
"emailFrom": "",
"smtpHost": "",
"smtpPort": 587,
"secure": false,
"ignoreTls": false,
"requireTls": false,
"allowSelfSigned": false,
"senderName": "Overseerr"
}
},
"discord": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": "",
"enableMentions": true
}
},
"lunasea": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"slack": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"telegram": {
"enabled": false,
"types": 0,
"options": {
"botAPI": "",
"chatId": "",
"sendSilently": false
}
},
"pushbullet": {
"enabled": false,
"types": 0,
"options": {
"accessToken": ""
}
},
"pushover": {
"enabled": false,
"types": 0,
"options": {
"accessToken": "",
"userToken": ""
}
},
"webhook": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": "",
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
}
},
"webpush": {
"enabled": false,
"options": {}
},
"gotify": {
"enabled": false,
"types": 0,
"options": {
"url": "",
"token": ""
}
"agents": {
"email": {
"enabled": false,
"options": {
"emailFrom": "",
"smtpHost": "",
"smtpPort": 587,
"secure": false,
"ignoreTls": false,
"requireTls": false,
"allowSelfSigned": false,
"senderName": "Overseerr"
}
},
"discord": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": "",
"enableMentions": true
}
},
"lunasea": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"slack": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"telegram": {
"enabled": false,
"types": 0,
"options": {
"botAPI": "",
"chatId": "",
"sendSilently": false
}
},
"pushbullet": {
"enabled": false,
"types": 0,
"options": {
"accessToken": ""
}
},
"pushover": {
"enabled": false,
"types": 0,
"options": {
"accessToken": "",
"userToken": ""
}
},
"webhook": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": "",
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
}
},
"webpush": {
"enabled": false,
"options": {}
},
"gotify": {
"enabled": false,
"types": 0,
"options": {
"url": "",
"token": ""
}
}
}
}
},
"jobs": {
"plex-recently-added-scan": {
"schedule": "0 */5 * * * *"
},
"plex-full-scan": {
"schedule": "0 0 3 * * *"
},
"radarr-scan": {
"schedule": "0 0 4 * * *"
},
"sonarr-scan": {
"schedule": "0 30 4 * * *"
},
"download-sync": {
"schedule": "0 * * * * *"
},
"download-sync-reset": {
"schedule": "0 0 1 * * *"
}
"plex-recently-added-scan": {
"schedule": "0 */5 * * * *"
},
"plex-full-scan": {
"schedule": "0 0 3 * * *"
},
"radarr-scan": {
"schedule": "0 0 4 * * *"
},
"sonarr-scan": {
"schedule": "0 30 4 * * *"
},
"download-sync": {
"schedule": "0 * * * * *"
},
"download-sync-reset": {
"schedule": "0 0 1 * * *"
}
}
}
}

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

@@ -28,6 +28,7 @@ docker run -d \
--name overseerr \
-e LOG_LEVEL=debug \
-e TZ=Asia/Tokyo \
-e PORT=5055 `#optional` \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
@@ -81,6 +82,7 @@ services:
environment:
- LOG_LEVEL=debug
- TZ=Asia/Tokyo
- PORT=5055 #optional
ports:
- 5055:5055
volumes:
@@ -88,7 +90,7 @@ services:
restart: unless-stopped
```
Then, start all services defined in the your Compose file:
Then, start all services defined in the Compose file:
```bash
docker-compose up -d
@@ -146,8 +148,6 @@ Then, create and start the Overseerr container:
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped sctx/overseerr:latest
```
If using a named volume like above, you can safely ignore the warning about the `/app/config` folder being incorrectly mounted on the setup page.
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
{% hint style="info" %}
@@ -155,7 +155,7 @@ Docker on Windows works differently than it does on Linux; it runs Docker inside
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
Named volumes, like in the example commands above, are automatically mounted inside the VM.
Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored.
{% endhint %}
## Linux

View File

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

View File

@@ -1273,6 +1273,8 @@ components:
type: string
userToken:
type: string
sound:
type: string
GotifySettings:
type: object
properties:
@@ -1708,6 +1710,9 @@ components:
pushoverUserKey:
type: string
nullable: true
pushoverSound:
type: string
nullable: true
telegramEnabled:
type: boolean
telegramBotUsername:
@@ -2861,6 +2866,33 @@ paths:
responses:
'204':
description: Test notification attempted
/settings/notifications/pushover/sounds:
get:
summary: Get Pushover sounds
description: Returns valid Pushover sound options in a JSON array.
tags:
- settings
parameters:
- in: query
name: token
required: true
schema:
type: string
nullable: false
responses:
'200':
description: Returned Pushover settings
content:
application/json:
schema:
type: array
items:
type: object
properties:
name:
type: string
description:
type: string
/settings/notifications/gotify:
get:
summary: Get Gotify notification settings
@@ -3615,7 +3647,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 +3743,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 +4218,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:
@@ -4418,7 +4460,7 @@ paths:
- in: query
name: genre
schema:
type: number
type: string
example: 18
- in: query
name: network
@@ -4465,6 +4507,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 +5370,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",
@@ -62,24 +62,22 @@
"lodash": "4.17.21",
"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",
@@ -87,43 +85,42 @@
"reflect-metadata": "0.1.13",
"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",
"sqlite3": "5.1.6",
"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 +129,45 @@
"@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

@@ -17,7 +17,7 @@
}
h1 {
color: #6366F1;
color: #6366f1;
}
p {
@@ -37,7 +37,7 @@
<!-- Inline the page's JavaScript file. -->
<script>
// Manual reload feature.
document.querySelector("button").addEventListener("click", () => {
document.querySelector('button').addEventListener('click', () => {
window.location.reload();
});

View File

@@ -4,30 +4,30 @@
// This variable is intentionally declared and unused.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const OFFLINE_VERSION = 3;
const CACHE_NAME = "offline";
const CACHE_NAME = 'offline';
// Customize this with a different URL if needed.
const OFFLINE_URL = "/offline.html";
const OFFLINE_URL = '/offline.html';
self.addEventListener("install", (event) => {
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
// Setting {cache: 'reload'} in the new request will ensure that the
// response isn't fulfilled from the HTTP cache; i.e., it will be from
// the network.
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
})()
);
// Force the waiting service worker to become the active service worker.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
// Enable navigation preload if it's supported.
// See https://developers.google.com/web/updates/2017/02/navigation-preload
if ("navigationPreload" in self.registration) {
if ('navigationPreload' in self.registration) {
await self.registration.navigationPreload.enable();
}
})()
@@ -37,10 +37,10 @@ self.addEventListener("activate", (event) => {
clients.claim();
});
self.addEventListener("fetch", (event) => {
self.addEventListener('fetch', (event) => {
// We only want to call event.respondWith() if this is a navigation request
// for an HTML page.
if (event.request.mode === "navigate") {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
try {
@@ -59,7 +59,7 @@ self.addEventListener("fetch", (event) => {
// If fetch() returns a valid HTTP response with a response code in
// the 4xx or 5xx range, the catch() will NOT be called.
// eslint-disable-next-line no-console
console.log("Fetch failed; returning offline page instead.", error);
console.log('Fetch failed; returning offline page instead.', error);
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(OFFLINE_URL);
@@ -85,15 +85,13 @@ self.addEventListener('push', (event) => {
requestId: payload.requestId,
},
actions: [],
}
};
if (payload.actionUrl){
options.actions.push(
{
action: 'view',
title: payload.actionUrlTitle ?? 'View',
}
);
if (payload.actionUrl) {
options.actions.push({
action: 'view',
title: payload.actionUrlTitle ?? 'View',
});
}
if (payload.notificationType === 'MEDIA_PENDING') {
@@ -109,27 +107,29 @@ self.addEventListener('push', (event) => {
);
}
event.waitUntil(
self.registration.showNotification(payload.subject, options)
);
event.waitUntil(self.registration.showNotification(payload.subject, options));
});
self.addEventListener('notificationclick', (event) => {
const notificationData = event.notification.data;
self.addEventListener(
'notificationclick',
(event) => {
const notificationData = event.notification.data;
event.notification.close();
event.notification.close();
if (event.action === 'approve') {
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
method: 'POST',
});
} else if (event.action === 'decline') {
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
method: 'POST',
});
}
if (notificationData.actionUrl) {
clients.openWindow(notificationData.actionUrl);
}
}, false);
if (event.action === 'approve') {
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
method: 'POST',
});
} else if (event.action === 'decline') {
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
method: 'POST',
});
}
if (notificationData.actionUrl) {
clients.openWindow(notificationData.actionUrl);
}
},
false
);

View File

@@ -226,12 +226,13 @@ class PlexAPI {
id: string,
options: { addedAt: number } = {
addedAt: Date.now() - 1000 * 60 * 60,
}
},
mediaType: 'movie' | 'show'
): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
options.addedAt / 1000
)}`,
uri: `/library/sections/${id}/all?type=${
mediaType === 'show' ? '4' : '1'
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
extraHeaders: {
'X-Plex-Container-Start': `0`,
'X-Plex-Container-Size': `500`,

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);

56
server/api/pushover.ts Normal file
View File

@@ -0,0 +1,56 @@
import ExternalAPI from './externalapi';
interface PushoverSoundsResponse {
sounds: {
[name: string]: string;
};
status: number;
request: string;
}
export interface PushoverSound {
name: string;
description: string;
}
export const mapSounds = (sounds: {
[name: string]: string;
}): PushoverSound[] =>
Object.entries(sounds).map(
([name, description]) =>
({
name,
description,
} as PushoverSound)
);
class PushoverAPI extends ExternalAPI {
constructor() {
super(
'https://api.pushover.net/1',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
}
public async getSounds(appToken: string): Promise<PushoverSound[]> {
try {
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
params: {
token: appToken,
},
});
return mapSounds(data.sounds);
} catch (e) {
throw new Error(`[Pushover] Failed to retrieve sounds: ${e.message}`);
}
}
}
export default PushoverAPI;

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,9 +85,11 @@ interface DiscoverTvOptions {
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
voteCountGte?: string;
voteCountLte?: string;
includeEmptyReleaseDate?: boolean;
originalLanguage?: string;
genre?: number;
genre?: string;
network?: number;
keywords?: string;
sortBy?: SortOptions;
@@ -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

@@ -51,6 +51,9 @@ export class UserSettings {
@Column({ nullable: true })
public pushoverUserKey?: string;
@Column({ nullable: true })
public pushoverSound?: string;
@Column({ nullable: true })
public telegramChatId?: string;

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';
@@ -151,7 +152,7 @@ app
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: true,
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
secure: 'auto',
},
store: new TypeormStore({
@@ -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

@@ -28,6 +28,7 @@ export interface UserSettingsNotificationsResponse {
pushbulletAccessToken?: string;
pushoverApplicationToken?: string;
pushoverUserKey?: string;
pushoverSound?: string;
telegramEnabled?: boolean;
telegramBotUsername?: string;
telegramChatId?: 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

@@ -159,6 +159,7 @@ class PushoverAgent
...notificationPayload,
token: settings.options.accessToken,
user: settings.options.userToken,
sound: settings.options.sound,
} as PushoverPayload);
} catch (e) {
logger.error('Error sending Pushover notification', {
@@ -198,6 +199,7 @@ class PushoverAgent
...notificationPayload,
token: payload.notifyUser.settings.pushoverApplicationToken,
user: payload.notifyUser.settings.pushoverUserKey,
sound: payload.notifyUser.settings.pushoverSound,
} as PushoverPayload);
} catch (e) {
logger.error('Error sending Pushover notification', {

View File

@@ -96,7 +96,8 @@ class PlexScanner
// We remove 10 minutes from the last scan as a buffer
addedAt: library.lastScan - 1000 * 60 * 10,
}
: undefined
: undefined,
library.type
);
// Bundle items up by rating keys

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;
@@ -189,6 +192,7 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
options: {
accessToken: string;
userToken: string;
sound: string;
};
}
@@ -248,7 +252,8 @@ export type JobId =
| 'sonarr-scan'
| 'download-sync'
| 'download-sync-reset'
| 'image-cache-cleanup';
| 'image-cache-cleanup'
| 'availability-sync';
interface AllSettings {
clientId: string;
@@ -368,6 +373,7 @@ class Settings {
options: {
accessToken: '',
userToken: '',
sound: '',
},
},
webhook: {
@@ -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 * * * * *',
},
@@ -546,7 +555,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

@@ -79,82 +79,80 @@ class WatchlistSync {
)
);
await Promise.all(
unavailableItems.map(async (mediaItem) => {
try {
logger.info("Creating media request from user's Plex Watchlist", {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
});
for (const mediaItem of unavailableItems) {
try {
logger.info("Creating media request from user's Plex Watchlist", {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
});
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
throw new Error('Missing TVDB ID from Plex Metadata');
}
// Check if they have auto-request permissons and watchlist sync
// enabled for the media type
if (
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncMovies) &&
mediaItem.type === 'movie') ||
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncTv) &&
mediaItem.type === 'show')
) {
return;
}
await MediaRequest.request(
{
mediaId: mediaItem.tmdbId,
mediaType:
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
seasons: mediaItem.type === 'show' ? 'all' : undefined,
tvdbId: mediaItem.tvdbId,
is4k: false,
},
user,
{ isAutoRequest: true }
);
} catch (e) {
if (!(e instanceof Error)) {
return;
}
switch (e.constructor) {
// During watchlist sync, these errors aren't necessarily
// a problem with Overseerr. Since we are auto syncing these constantly, it's
// possible they are unexpectedly at their quota limit, for example. So we'll
// instead log these as debug messages.
case RequestPermissionError:
case DuplicateMediaRequestError:
case QuotaRestrictedError:
case NoSeasonsAvailableError:
logger.debug('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
break;
default:
logger.error('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
}
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
throw new Error('Missing TVDB ID from Plex Metadata');
}
})
);
// Check if they have auto-request permissons and watchlist sync
// enabled for the media type
if (
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncMovies) &&
mediaItem.type === 'movie') ||
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncTv) &&
mediaItem.type === 'show')
) {
continue;
}
await MediaRequest.request(
{
mediaId: mediaItem.tmdbId,
mediaType:
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
seasons: mediaItem.type === 'show' ? 'all' : undefined,
tvdbId: mediaItem.tvdbId,
is4k: false,
},
user,
{ isAutoRequest: true }
);
} catch (e) {
if (!(e instanceof Error)) {
continue;
}
switch (e.constructor) {
// During watchlist sync, these errors aren't necessarily
// a problem with Overseerr. Since we are auto syncing these constantly, it's
// possible they are unexpectedly at their quota limit, for example. So we'll
// instead log these as debug messages.
case RequestPermissionError:
case DuplicateMediaRequestError:
case QuotaRestrictedError:
case NoSeasonsAvailableError:
logger.debug('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
break;
default:
logger.error('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,31 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserPushoverSound1697393491630 implements MigrationInterface {
name = 'AddUserPushoverSound1697393491630';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
}
}

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,
});
@@ -356,7 +361,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
genre: query.genre ? Number(query.genre) : undefined,
genre: query.genre,
network: query.network ? Number(query.network) : undefined,
firstAirDateLte: query.firstAirDateLte
? new Date(query.firstAirDateLte).toISOString().split('T')[0]
@@ -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,
});
@@ -491,7 +498,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId),
genre: req.params.genreId,
});
const media = await Media.getRelatedMedia(
@@ -647,6 +654,8 @@ discoverRoutes.get('/trending', async (req, res, next) => {
)
: isPerson(result)
? mapPersonResult(result)
: isCollection(result)
? mapCollectionResult(result)
: mapTvResult(
result,
media.find(
@@ -770,7 +779,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverTv({ genre: genre.id });
const genreData = await tmdb.getDiscoverTv({
genre: genre.id.toString(),
});
mappedGenres.push({
id: genre.id,
@@ -798,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({
@@ -827,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,5 @@
import GithubAPI from '@server/api/github';
import PushoverAPI from '@server/api/pushover';
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbMovieResult,
@@ -112,6 +113,31 @@ router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
return res.json(sliders);
});
router.get(
'/settings/notifications/pushover/sounds',
isAuthenticated(),
async (req, res, next) => {
const pushoverApi = new PushoverAPI();
try {
if (!req.query.token) {
throw new Error('Pushover application token missing from request');
}
const sounds = await pushoverApi.getSounds(req.query.token as string);
res.status(200).json(sounds);
} catch (e) {
logger.debug('Something went wrong retrieving Pushover sounds', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve Pushover sounds.',
});
}
}
);
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);

View File

@@ -308,7 +308,9 @@ issueRoutes.post<{ issueId: string }, Issue, { message: string }>(
issueRoutes.post<{ issueId: string; status: string }, Issue>(
'/:issueId/:status',
isAuthenticated(Permission.MANAGE_ISSUES),
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
async (req, res, next) => {
const issueRepository = getRepository(Issue);
// Satisfy typescript here. User is set, we assure you!
@@ -321,6 +323,16 @@ issueRoutes.post<{ issueId: string; status: string }, Issue>(
where: { id: Number(req.params.issueId) },
});
if (
!req.user?.hasPermission(Permission.MANAGE_ISSUES) &&
issue.createdBy.id !== req.user?.id
) {
return next({
status: 401,
message: 'You do not have permission to modify this issue.',
});
}
let newStatus: IssueStatus | undefined;
switch (req.params.status) {

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

@@ -262,7 +262,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
return res.status(200).json({
emailEnabled: settings?.email.enabled,
emailEnabled: settings.email.enabled,
pgpKey: user.settings?.pgpKey,
discordEnabled:
settings?.discord.enabled && settings.discord.options.enableMentions,
@@ -274,11 +274,12 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
pushoverUserKey: user.settings?.pushoverUserKey,
telegramEnabled: settings?.telegram.enabled,
telegramBotUsername: settings?.telegram.options.botUsername,
pushoverSound: user.settings?.pushoverSound,
telegramEnabled: settings.telegram.enabled,
telegramBotUsername: settings.telegram.options.botUsername,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
webPushEnabled: settings?.webpush.enabled,
telegramSendSilently: user.settings?.telegramSendSilently,
webPushEnabled: settings.webpush.enabled,
notificationTypes: user.settings?.notificationTypes ?? {},
});
} catch (e) {
@@ -329,6 +330,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
user.settings.pushoverApplicationToken =
req.body.pushoverApplicationToken;
user.settings.pushoverUserKey = req.body.pushoverUserKey;
user.settings.pushoverSound = req.body.pushoverSound;
user.settings.telegramChatId = req.body.telegramChatId;
user.settings.telegramSendSilently = req.body.telegramSendSilently;
user.settings.notificationTypes = Object.assign(
@@ -341,13 +343,14 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
userRepository.save(user);
return res.status(200).json({
pgpKey: user.settings?.pgpKey,
discordId: user.settings?.discordId,
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
pushoverUserKey: user.settings?.pushoverUserKey,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
pgpKey: user.settings.pgpKey,
discordId: user.settings.discordId,
pushbulletAccessToken: user.settings.pushbulletAccessToken,
pushoverApplicationToken: user.settings.pushoverApplicationToken,
pushoverUserKey: user.settings.pushoverUserKey,
pushoverSound: user.settings.pushoverSound,
telegramChatId: user.settings.telegramChatId,
telegramSendSilently: user.settings.telegramSendSilently,
notificationTypes: user.settings.notificationTypes,
});
} catch (e) {

View File

@@ -4,6 +4,7 @@ import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import IssueComment from '@server/entity/IssueComment';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
@@ -32,6 +33,10 @@ export class IssueCommentSubscriber
})
).issue;
const createdBy = await getRepository(User).findOneOrFail({
where: { id: issue.createdBy.id },
});
const media = await getRepository(Media).findOneOrFail({
where: { id: issue.media.id },
});
@@ -71,9 +76,9 @@ export class IssueCommentSubscriber
notifyAdmin: true,
notifySystem: true,
notifyUser:
!issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
issue.createdBy.id !== entity.user.id
? issue.createdBy
!createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
createdBy.id !== entity.user.id
? createdBy
: undefined,
});
}

View File

@@ -87,6 +87,7 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
notifySystem: true,
notifyUser:
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
entity.modifiedBy?.id !== entity.createdBy.id &&
(type === Notification.ISSUE_RESOLVED ||
type === Notification.ISSUE_REOPENED)
? entity.createdBy

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

@@ -16,9 +16,9 @@ architectures:
parts:
overseerr:
plugin: nodejs
nodejs-version: '16.17.0'
nodejs-version: '18.8.2'
nodejs-package-manager: 'yarn'
nodejs-yarn-version: v1.22.17
nodejs-yarn-version: v1.22.19
build-packages:
- git
- on arm64:

View File

@@ -37,6 +37,7 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})}
</Badge>
{showRelative && (

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 [
@@ -336,7 +348,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;
@@ -90,6 +91,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

@@ -12,6 +12,8 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import {
ArrowUturnLeftIcon,
Bars3Icon,
ChevronDownIcon,
ChevronUpIcon,
PencilIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
@@ -42,9 +44,12 @@ type DiscoverSliderEditProps = {
onDelete: () => void;
onPositionUpdate: (
updatedItemId: number,
position: keyof typeof Position
position: keyof typeof Position,
isClickable: boolean
) => void;
children: React.ReactNode;
disableUpButton: boolean;
disableDownButton: boolean;
};
const DiscoverSliderEdit = ({
@@ -53,6 +58,8 @@ const DiscoverSliderEdit = ({
onEnable,
onDelete,
onPositionUpdate,
disableUpButton,
disableDownButton,
}: DiscoverSliderEditProps) => {
const intl = useIntl();
const { addToast } = useToasts();
@@ -112,7 +119,7 @@ const DiscoverSliderEdit = ({
);
if (items?.[0]) {
const dropped = Number(items[0]);
onPositionUpdate(dropped, hoverPosition);
onPositionUpdate(dropped, hoverPosition, false);
}
},
});
@@ -157,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';
}
@@ -188,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 ${
@@ -271,7 +284,27 @@ const DiscoverSliderEdit = ({
</Button>
</>
)}
<div className="absolute top-4 right-4 flex-1 pl-4 text-right md:relative md:top-0 md:right-0">
<div className="absolute right-14 top-4 flex px-2 md:relative md:top-0 md:right-0">
<button
className={'hover:text-white disabled:text-gray-800'}
onClick={() =>
onPositionUpdate(Number(slider.id), Position.Above, true)
}
disabled={disableUpButton}
>
<ChevronUpIcon className="h-7 w-7 md:h-6 md:w-6" />
</button>
<button
className={'hover:text-white disabled:text-gray-800'}
onClick={() =>
onPositionUpdate(Number(slider.id), Position.Below, true)
}
disabled={disableDownButton}
>
<ChevronDownIcon className="h-7 w-7 md:h-6 md:w-6" />
</button>
</div>
<div className="absolute top-4 right-4 flex-1 text-right md:relative md:top-0 md:right-0">
<Tooltip content={intl.formatMessage(messages.enable)}>
<div>
<SlideCheckbox

View File

@@ -91,10 +91,10 @@ const DiscoverTv = () => {
<option value={SortOptions.PopularityAsc}>
{intl.formatMessage(messages.sortPopularityAsc)}
</option>
<option value={SortOptions.ReleaseDateDesc}>
<option value={SortOptions.FirstAirDateDesc}>
{intl.formatMessage(messages.sortFirstAirDateDesc)}
</option>
<option value={SortOptions.ReleaseDateAsc}>
<option value={SortOptions.FirstAirDateAsc}>
{intl.formatMessage(messages.sortFirstAirDateAsc)}
</option>
<option value={SortOptions.TmdbRatingDesc}>

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) {
@@ -384,7 +414,7 @@ const Discover = () => {
tempSliders[index].enabled = !tempSliders[index].enabled;
setSliders(tempSliders);
}}
onPositionUpdate={(updatedItemId, position) => {
onPositionUpdate={(updatedItemId, position, hasClickedArrows) => {
const originalPosition = sliders.findIndex(
(item) => item.id === updatedItemId
);
@@ -393,16 +423,24 @@ const Discover = () => {
const tempSliders = sliders.slice();
tempSliders.splice(originalPosition, 1);
tempSliders.splice(
position === 'Above' && index > originalPosition
? Math.max(index - 1, 0)
: index,
0,
originalItem
);
hasClickedArrows
? tempSliders.splice(
position === 'Above' ? index - 1 : index + 1,
0,
originalItem
)
: tempSliders.splice(
position === 'Above' && index > originalPosition
? Math.max(index - 1, 0)
: index,
0,
originalItem
);
setSliders(tempSliders);
}}
disableUpButton={index === 0}
disableDownButton={index === sliders.length - 1}
>
{sliderComponent}
</DiscoverSliderEdit>

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}
@@ -475,7 +475,8 @@ const IssueDetails = () => {
className="h-20"
/>
<div className="mt-4 flex items-center justify-end space-x-2">
{hasPermission(Permission.MANAGE_ISSUES) && (
{(hasPermission(Permission.MANAGE_ISSUES) ||
belongsToUser) && (
<>
{issueData.status === IssueStatus.OPEN ? (
<Button
@@ -666,6 +667,7 @@ const IssueDetails = () => {
</div>
</div>
</div>
<div className="extra-bottom-space" />
</div>
);
};

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

@@ -118,7 +118,11 @@ const MobileMenu = () => {
];
const filteredLinks = menuLinks.filter(
(link) => !link.requiredPermission || hasPermission(link.requiredPermission)
(link) =>
!link.requiredPermission ||
hasPermission(link.requiredPermission, {
type: link.permissionType ?? 'and',
})
);
return (
@@ -127,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);
@@ -163,28 +167,29 @@ const MobileMenu = () => {
</Transition>
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800 bg-opacity-90 backdrop-blur">
<div className="flex h-full items-center justify-between px-6 py-4 text-gray-100">
{filteredLinks.slice(0, 4).map((link) => {
const isActive =
router.pathname.match(link.activeRegExp) && !isOpen;
return (
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
<a
className={`flex flex-col items-center space-y-1 ${
isActive ? 'text-indigo-500' : ''
}`}
>
{cloneElement(
isActive ? link.svgIconSelected : link.svgIcon,
{
className: 'h-6 w-6',
}
)}
{/* <span className="text-sm">{link.content}</span> */}
</a>
</Link>
);
})}
{filteredLinks.length > 4 && (
{filteredLinks
.slice(0, filteredLinks.length === 5 ? 5 : 4)
.map((link) => {
const isActive =
router.pathname.match(link.activeRegExp) && !isOpen;
return (
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
<a
className={`flex flex-col items-center space-y-1 ${
isActive ? 'text-indigo-500' : ''
}`}
>
{cloneElement(
isActive ? link.svgIconSelected : link.svgIcon,
{
className: 'h-6 w-6',
}
)}
</a>
</Link>
);
})}
{filteredLinks.length > 4 && filteredLinks.length !== 5 && (
<button
className={`flex flex-col items-center space-y-1 ${
isOpen ? 'text-indigo-500' : ''
@@ -196,7 +201,6 @@ const MobileMenu = () => {
) : (
<EllipsisHorizontalIcon className="h-6 w-6" />
)}
{/* <span className="text-sm">{intl.formatMessage(messages.more)}</span> */}
</button>
)}
</div>

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>
))}

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(
@@ -503,44 +513,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>
)}
@@ -621,6 +649,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})}
</span>
</span>
@@ -640,6 +669,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})}
</span>
</div>
@@ -787,7 +817,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>
@@ -834,7 +864,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
linkUrl={`/movie/${data.id}/similar`}
hideWhenEmpty
/>
<div className="relative h-32 sm:h-8" />
<div className="extra-bottom-space relative" />
</div>
);
};

View File

@@ -91,11 +91,13 @@ const PersonDetails = () => {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}),
deathdate: intl.formatDate(data.deathday, {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}),
})
);
@@ -106,6 +108,7 @@ const PersonDetails = () => {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}),
})
);

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

@@ -76,8 +76,12 @@ const RegionSelector = ({
}, [value, regions, allRegion]);
useEffect(() => {
if (onChange && regions && selectedRegion) {
onChange(name, selectedRegion.iso_3166_1);
if (onChange && regions) {
if (selectedRegion) {
onChange(name, selectedRegion.iso_3166_1);
} else {
onChange(name, '');
}
}
}, [onChange, selectedRegion, name, regions]);
@@ -122,7 +126,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

@@ -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={`${

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