Compare commits

...

53 Commits

Author SHA1 Message Date
fallenbagel
0da85ca0d1 build(dockerfile): ignore scripts to not run husky install when devdependencies are pruned 2024-06-24 00:32:49 +05:00
fallenbagel
d5e37e4f3f build(dockerfile): install node-gyp through npm 2024-06-24 00:14:21 +05:00
fallenbagel
f5a50914c8 build(dockerfile): add node-gyp back in 2024-06-24 00:10:30 +05:00
fallenbagel
e30a3ea74f build: migrate pnpm 8 to 9 2024-06-24 00:06:52 +05:00
fallenbagel
cc80bf2c56 build(dockerfile): remove unnecessary && on apk installation steps 2024-06-23 23:50:16 +05:00
fallenbagel
2c52dbcea3 build: install pnpm for all platforms 2024-06-23 23:47:52 +05:00
fallenbagel
1168c77cad build(dockerfile): copy the proper pnpm lockfile 2024-06-23 23:38:02 +05:00
fallenbagel
182aeaa636 build(dockerfile): migrate to pnpm from yarn in docker builds 2024-06-23 23:35:01 +05:00
fallenbagel
a58811e675 ci: use sh shell to get pnpm store directory 2024-06-23 23:29:43 +05:00
fallenbagel
37ba36f2df ci: pnpm cache to reduce install time 2024-06-23 23:27:29 +05:00
fallenbagel
e50df22cbf ci(cypress): setup nodejs v20 in cypress workflow 2024-06-23 23:14:30 +05:00
fallenbagel
878afb91df style: ran prettier on pnpm-lock 2024-06-23 23:10:21 +05:00
fallenbagel
ffb20ebe93 test(cypress): use pnpm instead of yarn 2024-06-23 23:09:45 +05:00
fallenbagel
cb7e2f073e ci: fix typo in pnpm action-setup for cypress workflow 2024-06-23 23:04:30 +05:00
fallenbagel
588b1e36dd ci: specify the pnpm version to use in workflow actions 2024-06-23 23:01:58 +05:00
fallenbagel
99ee19c714 build: migrate yarn to pnpm and restrict engine to node@^20.0.0 2024-06-23 22:57:44 +05:00
gauthier-th
fac81f75c9 fix: resolve various UI issues 2024-06-23 18:12:58 +02:00
gauthier-th
ea43e3ba1e refactor: update Node.js to v20 2024-06-23 13:33:03 +02:00
gauthier-th
f3e180afb1 fix: resize avatar images 2024-06-23 11:48:22 +02:00
gauthier-th
b5738b49d6 chore: temporarily remove builds for ARMv7 2024-06-22 23:51:32 +02:00
gauthier-th
966a721c54 fix: resolve GitHub CodeQL alert 2024-06-21 19:11:45 +02:00
gauthier-th
43f8260675 fix: change extract script for i18n to a custom script 2024-06-21 18:34:32 +02:00
fallenbagel
50700002e2 chore: added sharp for production image optimisation 2024-06-20 20:15:04 +05:00
gauthier-th
f7de2418e5 fix: break word on long path to avoid text overflow 2024-06-20 16:58:24 +02:00
gauthier-th
5c212ae2a8 fix: resize logo in sidebar 2024-06-20 11:28:51 +02:00
gauthier-th
6b248d97a7 refactor: switch compiler from Babel to SWC 2024-06-20 00:08:02 +02:00
gauthier-th
5efa1d7a46 fix: resolve webpack cache issue with country-flag-icons 2024-06-19 21:12:00 +02:00
gauthier-th
cb8cadae71 chore: merge origin/develop 2024-06-19 16:59:26 +02:00
Fallenbagel
4d14a15fb6 chore: merge upstream/develop (#824)
* feat(pushover): attach image to pushover notification payload (#3701)

* fix: api language query parameter (#3720)

* docs: add j0srisk as a contributor for code (#3745) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* feat(tooltip): add tooltip to display exact time on date hover (#3773)

Co-authored-by: Loetwiek <lodommerholtcm@gmail.com>

* docs: add Loetwiek as a contributor for code (#3776) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* fix(ui): ensure title fits into the `view collection` box (#3696)

* fix(docs): correct openapi docs minor issues (#3648)

* docs: add Fuochi as a contributor for doc (#3826)

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* feat: translations update from Hosted Weblate (#3597)

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

Currently translated at 100.0% (1234 of 1234 strings)

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

Currently translated at 99.8% (1232 of 1234 strings)

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

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

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1234 of 1234 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Co-authored-by: Thomas Schöneberg <ta.schoeneberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1236 of 1236 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1234 of 1234 strings)

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

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

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1236 of 1236 strings)

Co-authored-by: BeardedWatermelon <BeardedWatermelon@users.noreply.hosted.weblate.org>
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 (Russian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 99.5% (1234 of 1240 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1234 of 1234 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: SoundwaveUwU <SoundwaveUwU@users.noreply.hosted.weblate.org>
Co-authored-by: SoundwaveUwU <noreply@1000-7.space>
Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com>
Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ru/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 37.1% (461 of 1240 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 37.0% (459 of 1240 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 34.8% (432 of 1240 strings)

Co-authored-by: Don Cezar <goldie.czr@gmail.com>
Co-authored-by: Dragos <themsk@yahoo.com>
Co-authored-by: Eduard Oancea <uberfly@420blaze.it>
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 (Bulgarian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Bulgarian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Bulgarian)

Currently translated at 57.4% (712 of 1240 strings)

feat(lang): translated using Weblate (Bulgarian)

Currently translated at 13.2% (164 of 1240 strings)

feat(lang): translated using Weblate (Bulgarian)

Currently translated at 4.8% (60 of 1240 strings)

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/bg/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 99.1% (1230 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 99.1% (1230 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 99.1% (1230 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 97.9% (1215 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 82.0% (1017 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 72.9% (905 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 72.9% (905 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 71.3% (885 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 64.9% (805 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 64.4% (799 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 63.8% (792 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 63.7% (791 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 57.5% (714 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 49.9% (619 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 35.9% (446 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 35.9% (446 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 32.1% (399 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 24.6% (306 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 18.9% (235 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 17.5% (217 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 17.3% (215 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 8.0% (100 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 3.3% (41 of 1240 strings)

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Michael Michael <michaelvelosk@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/uk/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1240 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
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 (Czech)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Czech)

Currently translated at 99.6% (1236 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Karel Krýda <karel.kryda@gmail.com>
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 (Croatian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.8% (1238 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.8% (1238 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.6% (1236 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.5% (1235 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.5% (1235 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.1% (1230 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 97.5% (1210 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.5% (1185 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.6% (1182 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.6% (1182 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.2% (1177 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.2% (1177 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 94.3% (1166 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 91.7% (1134 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 91.7% (1134 of 1236 strings)

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

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

Currently translated at 91.3% (1133 of 1240 strings)

feat(lang): translated using Weblate (Hungarian)

Currently translated at 89.3% (1108 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Levente Szajkó <leviko112@gmail.com>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hu/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 13.9% (172 of 1236 strings)

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

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

Currently translated at 99.1% (1225 of 1236 strings)

Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pl/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 92.8% (1148 of 1236 strings)

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

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

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1234 of 1234 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 (Dutch)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1234 of 1234 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/nl/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1236 of 1236 strings)

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 (French)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1236 of 1236 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.9% (1235 of 1236 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.9% (1235 of 1236 strings)

Co-authored-by: Baptiste <baptiste.nee@me.com>
Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Maxime Lafarie <maxime.lafarie@gmail.com>
Co-authored-by: Miguel <mig.mllr@gmail.com>
Co-authored-by: asurare <jonathan.biteau16@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1236 of 1236 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Per Erik <urbanlolface@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 2.6% (33 of 1240 strings)

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

Co-authored-by: Eero Konttaniemi <eero.konttaniemi@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: sct <sctsnipe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fi/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 50.8% (630 of 1240 strings)

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

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

Currently translated at 100.0% (1234 of 1234 strings)

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

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

Currently translated at 100.0% (1240 of 1240 strings)

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

Currently translated at 100.0% (1240 of 1240 strings)

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

Currently translated at 100.0% (1234 of 1234 strings)

Co-authored-by: Haohao Zhang <hyacz@foxmail.com>
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/zh_Hans/
Translation: Overseerr/Overseerr Frontend

---------

Co-authored-by: Cleiton Carvalho <cleitonsilvacarvalho@gmail.com>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Co-authored-by: Thomas Schöneberg <ta.schoeneberg@gmail.com>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Kenneth Hansen <erathor@live.dk>
Co-authored-by: BeardedWatermelon <BeardedWatermelon@users.noreply.hosted.weblate.org>
Co-authored-by: SoundwaveUwU <SoundwaveUwU@users.noreply.hosted.weblate.org>
Co-authored-by: SoundwaveUwU <noreply@1000-7.space>
Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com>
Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com>
Co-authored-by: Don Cezar <goldie.czr@gmail.com>
Co-authored-by: Dragos <themsk@yahoo.com>
Co-authored-by: Eduard Oancea <uberfly@420blaze.it>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Michael Michael <michaelvelosk@gmail.com>
Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Karel Krýda <karel.kryda@gmail.com>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Stjepan <stjepstjepanovic@gmail.com>
Co-authored-by: lpispek <lpispek@gmail.com>
Co-authored-by: Levente Szajkó <leviko112@gmail.com>
Co-authored-by: osh <osh@osh.cc>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Francesco <francy.ammirati@hotmail.com>
Co-authored-by: Fhd-pro <juve.11@msn.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Baptiste <baptiste.nee@me.com>
Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Maxime Lafarie <maxime.lafarie@gmail.com>
Co-authored-by: Miguel <mig.mllr@gmail.com>
Co-authored-by: asurare <jonathan.biteau16@gmail.com>
Co-authored-by: Per Erik <urbanlolface@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: Eero Konttaniemi <eero.konttaniemi@gmail.com>
Co-authored-by: Milan Smudja <smudja@gmail.com>
Co-authored-by: Developer J <jshsakura@gmail.com>
Co-authored-by: Haohao Zhang <hyacz@foxmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>

* feat(lang): add lang config for Bulgarian, Finnish, Ukrainian, Indonesian, Slovak, Turkish and Maori (#3834)

---------

Co-authored-by: Isaac M <masesisaac@gmail.com>
Co-authored-by: Joseph Risk <j0srisk@gmail.com>
Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
Co-authored-by: Loetwiek <79059734+Loetwiek@users.noreply.github.com>
Co-authored-by: Loetwiek <lodommerholtcm@gmail.com>
Co-authored-by: Fuochi <ffuochi@hotmail.com>
Co-authored-by: Weblate (bot) <hosted@weblate.org>
Co-authored-by: Cleiton Carvalho <cleitonsilvacarvalho@gmail.com>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Co-authored-by: Thomas Schöneberg <ta.schoeneberg@gmail.com>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Kenneth Hansen <erathor@live.dk>
Co-authored-by: BeardedWatermelon <BeardedWatermelon@users.noreply.hosted.weblate.org>
Co-authored-by: SoundwaveUwU <SoundwaveUwU@users.noreply.hosted.weblate.org>
Co-authored-by: SoundwaveUwU <noreply@1000-7.space>
Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com>
Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com>
Co-authored-by: Don Cezar <goldie.czr@gmail.com>
Co-authored-by: Dragos <themsk@yahoo.com>
Co-authored-by: Eduard Oancea <uberfly@420blaze.it>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Michael Michael <michaelvelosk@gmail.com>
Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Karel Krýda <karel.kryda@gmail.com>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Stjepan <stjepstjepanovic@gmail.com>
Co-authored-by: lpispek <lpispek@gmail.com>
Co-authored-by: Levente Szajkó <leviko112@gmail.com>
Co-authored-by: osh <osh@osh.cc>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Francesco <francy.ammirati@hotmail.com>
Co-authored-by: Fhd-pro <juve.11@msn.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Baptiste <baptiste.nee@me.com>
Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Maxime Lafarie <maxime.lafarie@gmail.com>
Co-authored-by: Miguel <mig.mllr@gmail.com>
Co-authored-by: asurare <jonathan.biteau16@gmail.com>
Co-authored-by: Per Erik <urbanlolface@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: Eero Konttaniemi <eero.konttaniemi@gmail.com>
Co-authored-by: Milan Smudja <smudja@gmail.com>
Co-authored-by: Developer J <jshsakura@gmail.com>
Co-authored-by: Haohao Zhang <hyacz@foxmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Co-authored-by: Jordan Jones <me@jjones.tech>
2024-06-19 18:40:25 +05:00
fallenbagel
06e465d052 build: fixes an issue where dev env could lead to javascript heap out of memory 2024-06-19 04:03:04 +05:00
gauthier-th
5fb1c687fc fix: temporary allow all domains for image optimization 2024-06-18 23:57:53 +02:00
gauthier-th
65239a922f fix: adjust full-size for next/image components 2024-06-17 15:06:57 +02:00
Gauthier
48d178c1e9 fix: add proper size to next/image components 2024-06-16 00:20:01 +02:00
Gauthier
5d6e7f09a2 fix: remove old intl polyfill 2024-06-16 00:17:45 +02:00
Gauthier
c680202008 refactor: update ESLint rules and fix warnings/errors 2024-06-15 16:32:24 +02:00
Gauthier
63d8f550c4 refactor: update Next.js images 2024-06-15 13:45:42 +02:00
Gauthier
9ab5fa5972 refactor: update Next.js and React.js 2024-06-14 00:42:51 +02:00
Fallenbagel
38ad875dd7 refactor(jellyfin): abstract jellyfin hostname, updated ui to reflect it, better validation (#773)
* refactor(jellyfinsettings): abstract jellyfin hostname, updated ui to reflect it, better validation

This PR refactors and abstracts jellyfin hostname into, jellyfin ip, jellyfin port, jellyfin useSsl,
and jellyfin urlBase. This makes it more consistent with how plex settings are stored as well. In
addition, this improves validation as validation can be applied seperately to them instead of as one
whole regex doing the work to validate the url.
UI was updated to reflect this.

BREAKING CHANGE: Jellyfin settings now does not include a hostname. Instead it abstracted it to ip,
port, useSsl, and urlBase. However, migration of old settings to new settings should work
automatically.

* refactor: remove console logs and use getHostname and ApiErrorCodes

* fix: store req.body jellyfin settings temporarily and store only if valid

This should fix the issue where settings are saved even if the url
was invalid. Now the settings will only be saved if the url is
valid. Sort of like a test connection.

* refactor: clean up commented out code

* refactor(i18n): extract translation keys

* fix(auth): auth failing with jellyfin login is disabled

* fix(settings): jellyfin migrations replacing the rest of the settings

* fix(settings): jellyfin hostname should be carried out if hostname exists

* fix(settings): merging the wrong settings source

* refactor(settings): use migrator for dynamic settings migrations

* refactor(settingsmigrator): settings migration handler and the migrations

* test(cypress): fix cypress tests failing

cypress settings were lacking some of the jobs so when the startJobs() is called when the app
starts, it was failing to schedule the jobs where their cron timings were not specified in the
cypress settings. Therefore, this commit adds those jobs back. In addition, other setting options
were added to keep cypress settings consistent with a normal user.

* chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier

* chore(prettier): ran formatter on cypress config to fix format check error

format check locally passes on this file. However, it fails during the github actions format check.
Therefore, json language features formatter was run instead of prettier to see if that fixes the
issue.

* test(cypress): add only missing jobs to the cypress settings

* ci: attempt at trying to get formatter to pass on cypress config json file

* refactor: revert the changes brought to try and fix formatter

added back the rest of the cypress settings and removed cypress settings from .prettierignore

* refactor(settings): better erorr logging when jellyfin connection test fails in settings page
2024-06-13 19:06:33 +02:00
Fallenbagel
a9741fa36d fix(auth): improve login resilience with headerless fallback authentication (#814)
adds fallback to authenticate without headers to ensure and improve resilience across different
browsers and client configurations.
2024-06-13 11:16:07 +02:00
Fallenbagel
b5a069901a fix: bypass cache-able lookups when resolving localhost (#813)
* fix: bypass cache-able lookups when resolving localhost

* fix: bypass cacheable-lookup when resolving localhost

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2024-06-13 04:53:12 +05:00
Fallenbagel
9aeb3604e6 fix(auth): validation of ipv6/ipv4 (#812)
validation for ipv6 was sort of broken where for example `::1` was being sent as `1`, therefore,
logins were broken. This PR fixes it by using nodejs `net.isIPv4()` & `net.isIPv6` for ipv4 and ipv6
validation.

possibly related to and fixes #795
2024-06-12 18:50:00 +05:00
Fallenbagel
6eb88f8674 ci: temporarily disable snap release builds (#811) 2024-06-12 10:49:15 +05:00
Gauthier
46ee8a4ca1 fix(api): add DNS caching (#810)
fix #387 #657 #728
2024-06-12 02:56:10 +05:00
Gauthier
f52939e4cd fix: remove the settings button of media when useless (#809)
After the Media Availability Sync job rund on deleted media, the setting button is still visible
even if neither the media file nor the media request no longer exists. This PR hides this button
when it's no longer the case
2024-06-11 19:47:02 +05:00
Gauthier
d31a2c37e6 fix(jellyfinscanner): assign only 4k available badge for a 4k request instead of both badges (#805)
When you have a 4k server setup, and request a 4k item, when it becomes available it also sets the
normal item as available thus not allowing the user to request for the normal item
2024-06-11 17:58:48 +05:00
Gauthier
20863d4a8d fix: empty email in user settings (#807)
Email is mandatory for every user and required during the setup of Jellyseerr, but it is possible to
set it empty afterwards in the user settings. When the email is empty, users are not able to connect
to Jellyseer. This PR makes the email field mandatory in the user settings.

fix #803
2024-06-11 16:23:35 +05:00
Fallenbagel
4757f1c3e5 Revert "ci: update format check command to ignore .prettierignore files (#787)" (#788)
This reverts commit 1f1ad72e9e.
2024-06-01 06:10:07 +05:00
Fallenbagel
1f1ad72e9e ci: update format check command to ignore .prettierignore files (#787)
This is to try and fix formatting issues on #773 on a file
that should be ignored.
2024-06-01 05:52:14 +05:00
allcontributors[bot]
c3ddc860b6 docs: add ThowZzy as a contributor for code (#779)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-29 00:32:43 +05:00
ThowZzy
2bd125d9a5 fix(auth): case-sensitive logins not updating authtokens (#778) 2024-05-28 23:42:26 +05:00
Fallenbagel
7a5e8d69bf feat(settings): stores jellyfin/emby server name in the settings (#763)
Stores jellyfin/emby(?) server name in the settings file. This might come in handy in the future
once simultaneous multi-server sync is implemented.
2024-05-26 18:21:14 +05:00
Fallenbagel
650c339d74 fix(jellyfinapi): use external api class for jellyfin api requests (#762)
* refactor(jellyfinapi): use the external api class for jellyfin api requests

refactors jellyfin api requests to be handled by the external api
to be consistent with how other external api requests are made

related #728, related #387

* style: prettier formatted

* refactor(jellyfinapi): rename device in auth header as jellyseerr

* refactor(error): rename api error code generic to unknown

* refactor(errorcodes): consistent casing of error code enums
2024-05-25 15:44:36 +05:00
Fallenbagel
4ef5a3c7c5 style: ran prettier on snap yaml file (#774) 2024-05-25 06:10:19 +05:00
218 changed files with 31519 additions and 16137 deletions

View File

@@ -367,6 +367,42 @@
"contributions": [ "contributions": [
"translation" "translation"
] ]
},
{
"login": "ThowZzy",
"name": "ThowZzy",
"avatar_url": "https://avatars.githubusercontent.com/u/61882536?v=4",
"profile": "https://github.com/ThowZzy",
"contributions": [
"code"
]
},
{
"login": "j0srisk",
"name": "Joseph Risk",
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
"profile": "http://josephrisk.com",
"contributions": [
"code"
]
},
{
"login": "Loetwiek",
"name": "Loetwiek",
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
"profile": "https://github.com/Loetwiek",
"contributions": [
"code"
]
},
{
"login": "Fuochi",
"name": "Fuochi",
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
"profile": "https://github.com/Fuochi",
"contributions": [
"doc"
]
} }
] ]
} }

View File

@@ -5,9 +5,7 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
'plugin:jsx-a11y/recommended', 'plugin:jsx-a11y/recommended',
'plugin:react/recommended', 'plugin:@next/next/recommended',
'plugin:react-hooks/recommended',
'plugin:react/jsx-runtime',
'prettier', 'prettier',
], ],
parserOptions: { parserOptions: {

View File

@@ -13,20 +13,35 @@ jobs:
name: Lint & Test Build name: Lint & Test Build
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container: node:18.18-alpine container: node:20-alpine
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
shell: sh
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
env: env:
HUSKY: 0 HUSKY: 0
run: yarn run: pnpm install
- name: Lint - name: Lint
run: yarn lint run: pnpm lint
- name: Formatting - name: Formatting
run: yarn format:check run: pnpm format:check
- name: Build - name: Build
run: yarn build run: pnpm build
build_and_push: build_and_push:
name: Build & Publish Docker Images name: Build & Publish Docker Images
@@ -60,7 +75,7 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
push: true push: true
build-args: | build-args: |
COMMIT_TAG=${{ github.sha }} COMMIT_TAG=${{ github.sha }}

View File

@@ -14,11 +14,19 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 9
- name: Cypress run - name: Cypress run
uses: cypress-io/github-action@v6 uses: cypress-io/github-action@v6
with: with:
build: yarn cypress:build build: pnpm cypress:build
start: yarn start start: pnpm start
wait-on: 'http://localhost:5055' wait-on: 'http://localhost:5055'
record: true record: true
env: env:

View File

@@ -29,7 +29,7 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
push: true push: true
build-args: | build-args: |
COMMIT_TAG=${{ github.sha }} COMMIT_TAG=${{ github.sha }}

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 20
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -26,8 +26,23 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
shell: sh
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: yarn run: pnpm install
- name: Release - name: Release
env: env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -35,60 +50,59 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: npx semantic-release run: npx semantic-release
build-snap: # build-snap:
name: Build Snap Package (${{ matrix.architecture }}) # name: Build Snap Package (${{ matrix.architecture }})
needs: semantic-release # needs: semantic-release
runs-on: ubuntu-22.04 # runs-on: ubuntu-22.04
strategy: # strategy:
fail-fast: false # fail-fast: false
matrix: # matrix:
architecture: # architecture:
- amd64 # - amd64
- arm64 # - arm64
- armhf # steps:
steps: # - name: Checkout Code
- name: Checkout Code # uses: actions/checkout@v4
uses: actions/checkout@v4 # with:
with: # fetch-depth: 0
fetch-depth: 0 # - name: Switch to main branch
- name: Switch to main branch # run: git checkout main
run: git checkout main # - name: Pull latest changes
- name: Pull latest changes # run: git pull
run: git pull # - name: Prepare
- name: Prepare # id: prepare
id: prepare # run: |
run: | # git fetch --prune --tags
git fetch --prune --tags # if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then # echo "RELEASE=stable" >> $GITHUB_OUTPUT
echo "RELEASE=stable" >> $GITHUB_OUTPUT # else
else # echo "RELEASE=edge" >> $GITHUB_OUTPUT
echo "RELEASE=edge" >> $GITHUB_OUTPUT # fi
fi # - name: Set Up QEMU
- name: Set Up QEMU # uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v3 # with:
with: # image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde # - name: Build Snap Package
- name: Build Snap Package # uses: diddlesnaps/snapcraft-multiarch-action@v1
uses: diddlesnaps/snapcraft-multiarch-action@v1 # id: build
id: build # with:
with: # architecture: ${{ matrix.architecture }}
architecture: ${{ matrix.architecture }} # - name: Upload Snap Package
- name: Upload Snap Package # uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4 # with:
with: # name: jellyseerr-snap-package-${{ matrix.architecture }}
name: jellyseerr-snap-package-${{ matrix.architecture }} # path: ${{ steps.build.outputs.snap }}
path: ${{ steps.build.outputs.snap }} # - name: Review Snap Package
- name: Review Snap Package # uses: diddlesnaps/snapcraft-review-tools-action@v1
uses: diddlesnaps/snapcraft-review-tools-action@v1 # with:
with: # snap: ${{ steps.build.outputs.snap }}
snap: ${{ steps.build.outputs.snap }} # - name: Publish Snap Package
- name: Publish Snap Package # uses: snapcore/action-publish@v1
uses: snapcore/action-publish@v1 # env:
env: # SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }} # with:
with: # snap: ${{ steps.build.outputs.snap }}
snap: ${{ steps.build.outputs.snap }} # release: ${{ steps.prepare.outputs.RELEASE }}
release: ${{ steps.prepare.outputs.RELEASE }}
discord: discord:
name: Send Discord Notification name: Send Discord Notification

View File

@@ -2,7 +2,7 @@ name: Publish Snap
# turn off edge snap builds temporarily and make it manual # turn off edge snap builds temporarily and make it manual
# on: # on:
# push: # push:
# branches: # branches:
# - develop # - develop
@@ -30,7 +30,6 @@ jobs:
architecture: architecture:
- amd64 - amd64
- arm64 - arm64
- armhf
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

View File

@@ -1,4 +1,4 @@
FROM node:18.18-alpine AS BUILD_IMAGE FROM node:20-alpine AS BUILD_IMAGE
WORKDIR /app WORKDIR /app
@@ -10,22 +10,24 @@ RUN \
'linux/arm64' | 'linux/arm/v7') \ 'linux/arm64' | 'linux/arm/v7') \
apk update && \ apk update && \
apk add --no-cache python3 make g++ gcc libc6-compat bash && \ apk add --no-cache python3 make g++ gcc libc6-compat bash && \
yarn global add node-gyp \ npm install --global node-gyp \
;; \ ;; \
esac esac
COPY package.json yarn.lock ./ Run npm install --global pnpm
RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
COPY package.json pnpm-lock.yaml ./
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
COPY . ./ COPY . ./
ARG COMMIT_TAG ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG} ENV COMMIT_TAG=${COMMIT_TAG}
RUN yarn build RUN pnpm build
# remove development dependencies # remove development dependencies
RUN yarn install --production --ignore-scripts --prefer-offline RUN pnpm prune --prod --ignore-scripts
RUN rm -rf src server .next/cache RUN rm -rf src server .next/cache
@@ -34,7 +36,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:18.18-alpine FROM node:20-alpine
# Metadata for Github Package Registry # Metadata for Github Package Registry
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr" LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
@@ -47,6 +49,6 @@ RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
COPY --from=BUILD_IMAGE /app ./ COPY --from=BUILD_IMAGE /app ./
ENTRYPOINT [ "/sbin/tini", "--" ] ENTRYPOINT [ "/sbin/tini", "--" ]
CMD [ "yarn", "start" ] CMD [ "pnpm", "start" ]
EXPOSE 5055 EXPOSE 5055

View File

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

View File

@@ -11,7 +11,7 @@
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a> <a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a> <a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-39-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. **Jellyseerr** is a free and open source software application for managing requests for your media library.
@@ -53,7 +53,7 @@ https://hub.docker.com/r/fallenbagel/jellyseerr
Pre-requisites: Pre-requisites:
- Nodejs [v18](https://nodejs.org/download/release/v18.18.2) - Nodejs [v20](https://nodejs.org/en/download)
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) - [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
- Download/git clone the source code from the github (Either develop branch or main for stable) - Download/git clone the source code from the github (Either develop branch or main for stable)
@@ -73,7 +73,7 @@ _To set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` i
**Pre-requisites:** **Pre-requisites:**
- Nodejs [v18](https://nodejs.org/en/download/package-manager) - Nodejs [v20](https://nodejs.org/en/download)
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on Debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`) - [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on Debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`)
- Git - Git
@@ -236,6 +236,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -377,6 +378,9 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
</tr> </tr>
<tr> <tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/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> <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>
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -1,25 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
[
'next/babel',
{
'preset-env': {
useBuiltIns: 'entry',
corejs: '3',
},
},
],
],
plugins: [
[
'react-intl-auto',
{
removePrefix: 'src/',
},
],
],
};
};

View File

@@ -19,6 +19,7 @@
"region": "", "region": "",
"originalLanguage": "", "originalLanguage": "",
"trustProxy": false, "trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true, "partialRequestsEnabled": true,
"locale": "en" "locale": "en"
}, },
@@ -37,6 +38,17 @@
], ],
"machineId": "test" "machineId": "test"
}, },
"jellyfin": {
"name": "",
"ip": "",
"port": 8096,
"useSsl": false,
"urlBase": "",
"externalHostname": "",
"jellyfinForgotPasswordUrl": "",
"libraries": [],
"serverId": ""
},
"tautulli": {}, "tautulli": {},
"radarr": [], "radarr": [],
"sonarr": [], "sonarr": [],
@@ -139,11 +151,26 @@
"sonarr-scan": { "sonarr-scan": {
"schedule": "0 30 4 * * *" "schedule": "0 30 4 * * *"
}, },
"plex-watchlist-sync": {
"schedule": "0 */10 * * * *"
},
"availability-sync": {
"schedule": "0 0 5 * * *"
},
"download-sync": { "download-sync": {
"schedule": "0 * * * * *" "schedule": "0 * * * * *"
}, },
"download-sync-reset": { "download-sync-reset": {
"schedule": "0 0 1 * * *" "schedule": "0 0 1 * * *"
},
"jellyfin-recently-added-scan": {
"schedule": "0 */5 * * * *"
},
"jellyfin-full-scan": {
"schedule": "0 0 3 * * *"
},
"image-cache-cleanup": {
"schedule": "0 0 5 * * *"
} }
} }
} }

View File

@@ -2,6 +2,6 @@ import './commands';
before(() => { before(() => {
if (Cypress.env('SEED_DATABASE')) { if (Cypress.env('SEED_DATABASE')) {
cy.exec('yarn cypress:prepare'); cy.exec('pnpm cypress:prepare');
} }
}); });

View File

@@ -10,7 +10,11 @@ module.exports = {
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE, JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
}, },
images: { images: {
domains: ['image.tmdb.org'], remotePatterns: [
{ hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' },
{ hostname: '*', protocol: 'https' },
],
}, },
webpack(config) { webpack(config) {
config.module.rules.push({ config.module.rules.push({

View File

@@ -3,26 +3,27 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts", "dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json", "build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
"build:next": "next build", "build:next": "next build",
"build": "yarn build:next && yarn build:server", "build": "pnpm build:next && pnpm build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix", "lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
"start": "NODE_ENV=production node dist/index.js", "start": "NODE_ENV=production node dist/index.js",
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"", "i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts",
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts", "migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts", "migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts", "migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
"format": "prettier --loglevel warn --write --cache .", "format": "prettier --loglevel warn --write --cache .",
"format:check": "prettier --check --cache .", "format:check": "prettier --check --cache .",
"typecheck": "yarn typecheck:server && yarn typecheck:client", "typecheck": "pnpm typecheck:server && pnpm typecheck:client",
"typecheck:server": "tsc --project server/tsconfig.json --noEmit", "typecheck:server": "tsc --project server/tsconfig.json --noEmit",
"typecheck:client": "tsc --noEmit", "typecheck:client": "tsc --noEmit",
"prepare": "husky install", "prepare": "husky install",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts", "cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
"cypress:build": "yarn build && yarn cypress:prepare" "cypress:build": "pnpm build && pnpm cypress:prepare"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -34,6 +35,7 @@
"@formatjs/intl-locale": "3.1.1", "@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.1.10", "@formatjs/intl-pluralrules": "5.1.10",
"@formatjs/intl-utils": "3.8.4", "@formatjs/intl-utils": "3.8.4",
"@formatjs/swc-plugin-experimental": "^0.4.0",
"@headlessui/react": "1.7.12", "@headlessui/react": "1.7.12",
"@heroicons/react": "2.0.16", "@heroicons/react": "2.0.16",
"@supercharge/request-ip": "1.2.0", "@supercharge/request-ip": "1.2.0",
@@ -44,6 +46,7 @@
"axios-rate-limit": "1.3.0", "axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.11.0", "bowser": "2.11.0",
"cacheable-lookup": "^7.0.0",
"connect-typeorm": "1.1.4", "connect-typeorm": "1.1.4",
"cookie-parser": "1.4.6", "cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.3", "copy-to-clipboard": "3.3.3",
@@ -58,11 +61,10 @@
"express-openapi-validator": "4.13.8", "express-openapi-validator": "4.13.8",
"express-rate-limit": "6.7.0", "express-rate-limit": "6.7.0",
"express-session": "1.17.3", "express-session": "1.17.3",
"formik": "2.2.9", "formik": "^2.4.6",
"gravatar-url": "3.1.0", "gravatar-url": "3.1.0",
"intl": "1.2.5",
"lodash": "4.17.21", "lodash": "4.17.21",
"next": "12.3.4", "next": "^14.2.4",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-gyp": "9.3.1", "node-gyp": "9.3.1",
"node-schedule": "2.1.1", "node-schedule": "2.1.1",
@@ -70,13 +72,13 @@
"openpgp": "5.7.0", "openpgp": "5.7.0",
"plex-api": "5.3.2", "plex-api": "5.3.2",
"pug": "3.0.2", "pug": "3.0.2",
"react": "18.2.0", "react": "^18.3.1",
"react-ace": "10.1.0", "react-ace": "10.1.0",
"react-animate-height": "2.1.2", "react-animate-height": "2.1.2",
"react-aria": "3.23.0", "react-aria": "3.23.0",
"react-dom": "18.2.0", "react-dom": "^18.3.1",
"react-intersection-observer": "9.4.3", "react-intersection-observer": "9.4.3",
"react-intl": "6.2.10", "react-intl": "^6.6.8",
"react-markdown": "8.0.5", "react-markdown": "8.0.5",
"react-popper-tooltip": "4.4.2", "react-popper-tooltip": "4.4.2",
"react-select": "5.7.0", "react-select": "5.7.0",
@@ -88,9 +90,10 @@
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"secure-random-password": "0.2.3", "secure-random-password": "0.2.3",
"semver": "7.3.8", "semver": "7.3.8",
"sharp": "^0.33.4",
"sqlite3": "5.1.4", "sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2", "swagger-ui-express": "4.6.2",
"swr": "2.0.4", "swr": "2.2.5",
"typeorm": "0.3.12", "typeorm": "0.3.12",
"web-push": "3.5.0", "web-push": "3.5.0",
"winston": "3.8.2", "winston": "3.8.2",
@@ -101,7 +104,6 @@
"zod": "3.20.6" "zod": "3.20.6"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "7.21.0",
"@commitlint/cli": "17.4.4", "@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4", "@commitlint/config-conventional": "17.4.4",
"@semantic-release/changelog": "6.0.2", "@semantic-release/changelog": "6.0.2",
@@ -122,8 +124,8 @@
"@types/node": "17.0.36", "@types/node": "17.0.36",
"@types/node-schedule": "2.1.0", "@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",
"@types/react": "18.0.28", "@types/react": "^18.3.3",
"@types/react-dom": "18.0.11", "@types/react-dom": "^18.3.0",
"@types/react-transition-group": "4.4.5", "@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1", "@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.13", "@types/semver": "7.3.13",
@@ -135,15 +137,13 @@
"@typescript-eslint/eslint-plugin": "5.54.0", "@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.54.0", "@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.13", "autoprefixer": "10.4.13",
"babel-plugin-react-intl": "8.2.25",
"babel-plugin-react-intl-auto": "3.3.0",
"commitizen": "4.3.0", "commitizen": "4.3.0",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0", "cy-mobile-commands": "0.3.0",
"cypress": "12.7.0", "cypress": "12.7.0",
"cz-conventional-changelog": "3.3.0", "cz-conventional-changelog": "3.3.0",
"eslint": "8.35.0", "eslint": "8.35.0",
"eslint-config-next": "12.3.4", "eslint-config-next": "^14.2.4",
"eslint-config-prettier": "8.6.0", "eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.9.0", "eslint-plugin-formatjs": "4.9.0",
"eslint-plugin-jsx-a11y": "6.7.1", "eslint-plugin-jsx-a11y": "6.7.1",
@@ -151,7 +151,6 @@
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2", "eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"extract-react-intl-messages": "4.1.1",
"husky": "8.0.3", "husky": "8.0.3",
"lint-staged": "13.1.2", "lint-staged": "13.1.2",
"nodemon": "2.0.20", "nodemon": "2.0.20",
@@ -167,10 +166,12 @@
"tsconfig-paths": "4.1.2", "tsconfig-paths": "4.1.2",
"typescript": "4.9.5" "typescript": "4.9.5"
}, },
"resolutions": { "engines": {
"node": "^20.0.0",
"pnpm": "^9.0.0"
},
"overrides": {
"sqlite3/node-gyp": "8.4.1", "sqlite3/node-gyp": "8.4.1",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/express-session": "1.17.6" "@types/express-session": "1.17.6"
}, },
"config": { "config": {
@@ -236,8 +237,7 @@
], ],
"platforms": [ "platforms": [
"linux/amd64", "linux/amd64",
"linux/arm64", "linux/arm64"
"linux/arm/v7"
] ]
}, },
"@semantic-release/github" "@semantic-release/github"

26267
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import ExternalAPI from '@server/api/externalapi';
import { ApiErrorCode } from '@server/constants/error'; import { ApiErrorCode } from '@server/constants/error';
import availabilitySync from '@server/lib/availabilitySync'; import availabilitySync from '@server/lib/availabilitySync';
import logger from '@server/logger'; import logger from '@server/logger';
import { ApiError } from '@server/types/error'; import { ApiError } from '@server/types/error';
import type { AxiosInstance } from 'axios'; import { getAppVersion } from '@server/utils/appVersion';
import axios from 'axios';
export interface JellyfinUserResponse { export interface JellyfinUserResponse {
Name: string; Name: string;
@@ -92,31 +92,33 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
DateCreated?: string; DateCreated?: string;
} }
class JellyfinAPI { class JellyfinAPI extends ExternalAPI {
private authToken?: string; private authToken?: string;
private userId?: string; private userId?: string;
private jellyfinHost: string; private jellyfinHost: string;
private axios: AxiosInstance;
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
this.jellyfinHost = jellyfinHost; let authHeaderVal: string;
this.authToken = authToken; if (authToken) {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
let authHeaderVal = '';
if (this.authToken) {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="10.8.0", Token="${authToken}"`;
} else { } else {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="10.8.0"`; authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`;
} }
this.axios = axios.create({ super(
baseURL: this.jellyfinHost, jellyfinHost,
headers: { {},
'X-Emby-Authorization': authHeaderVal, {
'Content-Type': 'application/json', headers: {
Accept: 'application/json', 'X-Emby-Authorization': authHeaderVal,
}, 'Content-Type': 'application/json',
}); Accept: 'application/json',
},
}
);
this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
} }
public async login( public async login(
@@ -124,24 +126,31 @@ class JellyfinAPI {
Password?: string, Password?: string,
ClientIP?: string ClientIP?: string
): Promise<JellyfinLoginResponse> { ): Promise<JellyfinLoginResponse> {
try { const authenticate = async (useHeaders: boolean) => {
const headers = ClientIP const headers =
? { useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
'X-Forwarded-For': ClientIP,
} return this.post<JellyfinLoginResponse>(
: {};
const account = await this.axios.post<JellyfinLoginResponse>(
'/Users/AuthenticateByName', '/Users/AuthenticateByName',
{ {
Username: Username, Username,
Pw: Password, Pw: Password,
}, },
{ { headers }
headers: headers,
}
); );
};
return account.data; try {
return await authenticate(true);
} catch (e) {
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
label: 'Jellyfin API',
ip: ClientIP,
});
}
try {
return await authenticate(false);
} catch (e) { } catch (e) {
const status = e.response?.status; const status = e.response?.status;
@@ -175,68 +184,84 @@ class JellyfinAPI {
return; return;
} }
public async getSystemInfo(): Promise<any> {
try {
const systemInfoResponse = await this.get<any>('/System/Info');
return systemInfoResponse;
} catch (e) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getServerName(): Promise<string> { public async getServerName(): Promise<string> {
try { try {
const account = await this.axios.get<JellyfinUserResponse>( const serverResponse = await this.get<JellyfinUserResponse>(
"/System/Info/Public'}" '/System/Info/Public'
); );
return account.data.ServerName;
return serverResponse.ServerName;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`, `Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('girl idk');
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
} }
} }
public async getUsers(): Promise<JellyfinUserListResponse> { public async getUsers(): Promise<JellyfinUserListResponse> {
try { try {
const account = await this.axios.get(`/Users`); const userReponse = await this.get<JellyfinUserResponse[]>(`/Users`);
return { users: account.data };
return { users: userReponse };
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`, `Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getUser(): Promise<JellyfinUserResponse> { public async getUser(): Promise<JellyfinUserResponse> {
try { try {
const account = await this.axios.get<JellyfinUserResponse>( const userReponse = await this.get<JellyfinUserResponse>(
`/Users/${this.userId ?? 'Me'}` `/Users/${this.userId ?? 'Me'}`
); );
return account.data; return userReponse;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`, `Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getLibraries(): Promise<JellyfinLibrary[]> { public async getLibraries(): Promise<JellyfinLibrary[]> {
try { try {
const mediaFolders = await this.axios.get<any>(`/Library/MediaFolders`); const mediaFolderResponse = await this.get<any>(`/Library/MediaFolders`);
return this.mapLibraries(mediaFolders.data.Items); return this.mapLibraries(mediaFolderResponse.Items);
} catch (mediaFoldersError) { } catch (mediaFoldersResponseError) {
// fallback to user views to get libraries // fallback to user views to get libraries
// this only affects LDAP users // this only and maybe/depending on factors affects LDAP users
try { try {
const mediaFolders = await this.axios.get<any>( const mediaFolderResponse = await this.get<any>(
`/Users/${this.userId ?? 'Me'}/Views` `/Users/${this.userId ?? 'Me'}/Views`
); );
return this.mapLibraries(mediaFolders.data.Items); return this.mapLibraries(mediaFolderResponse.Items);
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`, `Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
return []; return [];
} }
} }
@@ -270,11 +295,11 @@ class JellyfinAPI {
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> { public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const contents = await this.axios.get<any>( const libraryItemsResponse = await this.get<any>(
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false` `/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
); );
return contents.data.Items.filter( return libraryItemsResponse.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual' (item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
); );
} catch (e) { } catch (e) {
@@ -282,23 +307,25 @@ class JellyfinAPI {
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> { public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const contents = await this.axios.get<any>( const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}` `/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
); );
return contents.data; return itemResponse;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -306,36 +333,38 @@ class JellyfinAPI {
id: string id: string
): Promise<JellyfinLibraryItemExtended | undefined> { ): Promise<JellyfinLibraryItemExtended | undefined> {
try { try {
const contents = await this.axios.get<any>( const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/${id}` `/Users/${this.userId}/Items/${id}`
); );
return contents.data; return itemResponse;
} catch (e) { } catch (e) {
if (availabilitySync.running) { if (availabilitySync.running) {
if (e.response && e.response.status === 500) { if (e.response && e.response.status === 500) {
return undefined; return undefined;
} }
} }
logger.error( logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token'); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> { public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> {
try { try {
const contents = await this.axios.get<any>(`/Shows/${seriesID}/Seasons`); const seasonResponse = await this.get<any>(`/Shows/${seriesID}/Seasons`);
return contents.data.Items; return seasonResponse.Items;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`, `Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -344,11 +373,11 @@ class JellyfinAPI {
seasonID: string seasonID: string
): Promise<JellyfinLibraryItem[]> { ): Promise<JellyfinLibraryItem[]> {
try { try {
const contents = await this.axios.get<any>( const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}` `/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
); );
return contents.data.Items.filter( return episodeResponse.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual' (item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
); );
} catch (e) { } catch (e) {
@@ -356,7 +385,8 @@ class JellyfinAPI {
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`, `Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
} }

View File

@@ -1,5 +1,9 @@
export enum ApiErrorCode { export enum ApiErrorCode {
InvalidUrl = 'INVALID_URL', InvalidUrl = 'INVALID_URL',
InvalidCredentials = 'INVALID_CREDENTIALS', InvalidCredentials = 'INVALID_CREDENTIALS',
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
NotAdmin = 'NOT_ADMIN', NotAdmin = 'NOT_ADMIN',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unknown = 'UNKNOWN',
} }

View File

@@ -9,6 +9,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
import { import {
AfterLoad, AfterLoad,
Column, Column,
@@ -211,15 +212,12 @@ class Media {
} else { } else {
const pageName = const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details'; process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } = getSettings().jellyfin; const { serverId, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : getHostname();
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
if (this.jellyfinMediaId) { if (this.jellyfinMediaId) {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;

View File

@@ -23,19 +23,25 @@ import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag'; import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip'; import { getClientIp } from '@supercharge/request-ip';
import type CacheableLookupType from 'cacheable-lookup';
import { TypeormStore } from 'connect-typeorm/out'; import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import csurf from 'csurf'; import csurf from 'csurf';
import { lookup } from 'dns';
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import express from 'express'; import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator'; import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session'; import type { Store } from 'express-session';
import session from 'express-session'; import session from 'express-session';
import next from 'next'; import next from 'next';
import http from 'node:http';
import https from 'node:https';
import path from 'path'; import path from 'path';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs'; import YAML from 'yamljs';
const _importDynamic = new Function('modulePath', 'return import(modulePath)');
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Overseerr version ${getAppVersion()}`); logger.info(`Starting Overseerr version ${getAppVersion()}`);
@@ -46,6 +52,25 @@ const handle = app.getRequestHandler();
app app
.prepare() .prepare()
.then(async () => { .then(async () => {
const CacheableLookup = (await _importDynamic('cacheable-lookup'))
.default as typeof CacheableLookupType;
const cacheable = new CacheableLookup();
const originalLookup = cacheable.lookup;
// if hostname is localhost use dns.lookup instead of cacheable-lookup
cacheable.lookup = (...args: any) => {
const [hostname] = args;
if (hostname === 'localhost') {
lookup(...(args as Parameters<typeof lookup>));
} else {
originalLookup(...(args as Parameters<typeof originalLookup>));
}
};
cacheable.install(http.globalAgent);
cacheable.install(https.globalAgent);
const dbConnection = await dataSource.initialize(); const dbConnection = await dataSource.initialize();
// Run migrations in production // Run migrations in production
@@ -121,7 +146,7 @@ app
try { try {
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip'); const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
if (descriptor?.writable === true) { if (descriptor?.writable === true) {
req.ip = getClientIp(req) ?? ''; (req as any).ip = getClientIp(req) ?? '';
} }
} catch (e) { } catch (e) {
logger.error('Failed to attach the ip to the request', { logger.error('Failed to attach the ip to the request', {

View File

@@ -16,6 +16,7 @@ import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
class AvailabilitySync { class AvailabilitySync {
public running = false; public running = false;
@@ -84,7 +85,7 @@ class AvailabilitySync {
) { ) {
if (admin) { if (admin) {
this.jellyfinClient = new JellyfinAPI( this.jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '', getHostname(),
admin.jellyfinAuthToken, admin.jellyfinAuthToken,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

@@ -14,7 +14,12 @@ import {
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent'; import { BaseAgent } from './agent';
interface PushoverPayload { interface PushoverImagePayload {
attachment_base64: string;
attachment_type: string;
}
interface PushoverPayload extends PushoverImagePayload {
token: string; token: string;
user: string; user: string;
title: string; title: string;
@@ -43,10 +48,36 @@ class PushoverAgent
return true; return true;
} }
private getNotificationPayload( private async getImagePayload(
imageUrl: string
): Promise<Partial<PushoverImagePayload>> {
try {
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer',
});
const base64 = Buffer.from(response.data, 'binary').toString('base64');
const contentType = (
response.headers['Content-Type'] || response.headers['content-type']
)?.toString();
return {
attachment_base64: base64,
attachment_type: contentType,
};
} catch (e) {
logger.error('Error getting image payload', {
label: 'Notifications',
errorMessage: e.message,
response: e.response?.data,
});
return {};
}
}
private async getNotificationPayload(
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): Partial<PushoverPayload> { ): Promise<Partial<PushoverPayload>> {
const { applicationUrl, applicationTitle } = getSettings().main; const { applicationUrl, applicationTitle } = getSettings().main;
const title = payload.event ?? payload.subject; const title = payload.event ?? payload.subject;
@@ -122,6 +153,16 @@ class PushoverAgent
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}` ? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
: undefined; : undefined;
let attachment_base64;
let attachment_type;
if (payload.image) {
const imagePayload = await this.getImagePayload(payload.image);
if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
attachment_base64 = imagePayload.attachment_base64;
attachment_type = imagePayload.attachment_type;
}
}
return { return {
title, title,
message, message,
@@ -129,6 +170,8 @@ class PushoverAgent
url_title, url_title,
priority, priority,
html: 1, html: 1,
attachment_base64,
attachment_type,
}; };
} }
@@ -138,7 +181,10 @@ class PushoverAgent
): Promise<boolean> { ): Promise<boolean> {
const settings = this.getSettings(); const settings = this.getSettings();
const endpoint = 'https://api.pushover.net/1/messages.json'; const endpoint = 'https://api.pushover.net/1/messages.json';
const notificationPayload = this.getNotificationPayload(type, payload); const notificationPayload = await this.getNotificationPayload(
type,
payload
);
// Send system notification // Send system notification
if ( if (

View File

@@ -12,6 +12,7 @@ import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock'; import AsyncLock from '@server/utils/asyncLock';
import { getHostname } from '@server/utils/getHostname';
import { randomUUID as uuid } from 'crypto'; import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash'; import { uniqWith } from 'lodash';
@@ -83,13 +84,17 @@ class JellyfinScanner {
} }
const has4k = metadata.MediaSources?.some((MediaSource) => { const has4k = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => { return MediaSource.MediaStreams.filter(
(MediaStream) => MediaStream.Type === 'Video'
).some((MediaStream) => {
return (MediaStream.Width ?? 0) > 2000; return (MediaStream.Width ?? 0) > 2000;
}); });
}); });
const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => { const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => { return MediaSource.MediaStreams.filter(
(MediaStream) => MediaStream.Type === 'Video'
).some((MediaStream) => {
return (MediaStream.Width ?? 0) <= 2000; return (MediaStream.Width ?? 0) <= 2000;
}); });
}); });
@@ -590,8 +595,10 @@ class JellyfinScanner {
return this.log('No admin configured. Jellyfin sync skipped.', 'warn'); return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
} }
const hostname = getHostname();
this.jfClient = new JellyfinAPI( this.jfClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '', hostname,
admin.jellyfinAuthToken, admin.jellyfinAuthToken,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

@@ -1,10 +1,11 @@
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import fs from 'fs'; import fs from 'fs';
import { merge } from 'lodash'; import { merge } from 'lodash';
import path from 'path'; import path from 'path';
import webpush from 'web-push'; import webpush from 'web-push';
import { Permission } from './permissions';
export interface Library { export interface Library {
id: string; id: string;
@@ -38,7 +39,10 @@ export interface PlexSettings {
export interface JellyfinSettings { export interface JellyfinSettings {
name: string; name: string;
hostname: string; ip: string;
port: number;
useSsl?: boolean;
urlBase?: string;
externalHostname?: string; externalHostname?: string;
jellyfinForgotPasswordUrl?: string; jellyfinForgotPasswordUrl?: string;
libraries: Library[]; libraries: Library[];
@@ -130,7 +134,6 @@ interface FullPublicSettings extends PublicSettings {
region: string; region: string;
originalLanguage: string; originalLanguage: string;
mediaServerType: number; mediaServerType: number;
jellyfinHost?: string;
jellyfinExternalHost?: string; jellyfinExternalHost?: string;
jellyfinForgotPasswordUrl?: string; jellyfinForgotPasswordUrl?: string;
jellyfinServerName?: string; jellyfinServerName?: string;
@@ -274,7 +277,7 @@ export type JobId =
| 'image-cache-cleanup' | 'image-cache-cleanup'
| 'availability-sync'; | 'availability-sync';
interface AllSettings { export interface AllSettings {
clientId: string; clientId: string;
vapidPublic: string; vapidPublic: string;
vapidPrivate: string; vapidPrivate: string;
@@ -291,7 +294,7 @@ interface AllSettings {
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/settings.json` ? `${process.env.CONFIG_DIRECTORY}/settings.json`
: path.join(__dirname, '../../config/settings.json'); : path.join(__dirname, '../../../config/settings.json');
class Settings { class Settings {
private data: AllSettings; private data: AllSettings;
@@ -331,7 +334,10 @@ class Settings {
}, },
jellyfin: { jellyfin: {
name: '', name: '',
hostname: '', ip: '',
port: 8096,
useSsl: false,
urlBase: '',
externalHostname: '', externalHostname: '',
jellyfinForgotPasswordUrl: '', jellyfinForgotPasswordUrl: '',
libraries: [], libraries: [],
@@ -547,8 +553,6 @@ class Settings {
region: this.data.main.region, region: this.data.main.region,
originalLanguage: this.data.main.originalLanguage, originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType, mediaServerType: this.main.mediaServerType,
jellyfinHost: this.jellyfin.hostname,
jellyfinExternalHost: this.jellyfin.externalHostname,
partialRequestsEnabled: this.data.main.partialRequestsEnabled, partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages, cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic, vapidPublic: this.vapidPublic,
@@ -637,7 +641,11 @@ class Settings {
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8'); const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) { if (data) {
this.data = merge(this.data, JSON.parse(data)); const parsedJson = JSON.parse(data);
this.data = runMigrations(parsedJson);
this.data = merge(this.data, parsedJson);
this.save(); this.save();
} }
return this; return this;

View File

@@ -0,0 +1,30 @@
import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => {
const oldJellyfinSettings = settings.jellyfin;
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
const { hostname } = oldJellyfinSettings;
const protocolMatch = hostname.match(/^(https?):\/\//i);
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
delete oldJellyfinSettings.hostname;
if (urlMatch) {
const [, ip, , port, urlBase] = urlMatch;
settings.jellyfin = {
...settings.jellyfin,
ip,
port: port || (useSsl ? 443 : 80),
useSsl,
urlBase: urlBase ? urlBase.replace(/\/$/, '') : '',
};
}
}
if (settings.jellyfin && settings.jellyfin.hostname) {
delete settings.jellyfin.hostname;
}
return settings;
};
export default migrateHostname;

View File

@@ -0,0 +1,21 @@
import type { AllSettings } from '@server/lib/settings';
import fs from 'fs';
import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = (settings: AllSettings): AllSettings => {
const migrations = fs
.readdirSync(migrationsDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
// eslint-disable-next-line @typescript-eslint/no-var-requires
.map((file) => require(path.join(migrationsDir, file)).default);
let migrated = settings;
for (const migration of migrations) {
migrated = migration(migrated);
}
return migrated;
};

View File

@@ -11,9 +11,11 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error'; import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator'; import * as EmailValidator from 'email-validator';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
import net from 'net';
const authRoutes = Router(); const authRoutes = Router();
@@ -221,30 +223,39 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
username?: string; username?: string;
password?: string; password?: string;
hostname?: string; hostname?: string;
port?: number;
urlBase?: string;
useSsl?: boolean;
email?: string; email?: string;
}; };
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured //Make sure jellyfin login is enabled, but only if jellyfin is not already configured
if ( if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN && settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.jellyfin.hostname !== '' settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
) { ) {
return res.status(500).json({ error: 'Jellyfin login is disabled' }); return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.username) { } else if (!body.username) {
return res.status(500).json({ error: 'You must provide an username' }); return res.status(500).json({ error: 'You must provide an username' });
} else if (settings.jellyfin.hostname !== '' && body.hostname) { } else if (settings.jellyfin.ip !== '' && body.hostname) {
return res return res
.status(500) .status(500)
.json({ error: 'Jellyfin hostname already configured' }); .json({ error: 'Jellyfin hostname already configured' });
} else if (settings.jellyfin.hostname === '' && !body.hostname) { } else if (settings.jellyfin.ip === '' && !body.hostname) {
return res.status(500).json({ error: 'No hostname provided.' }); return res.status(500).json({ error: 'No hostname provided.' });
} }
try { try {
const hostname = const hostname =
settings.jellyfin.hostname !== '' settings.jellyfin.ip !== ''
? settings.jellyfin.hostname ? getHostname()
: body.hostname ?? ''; : getHostname({
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
});
const { externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one // Try to find deviceId that corresponds to jellyfin user, else generate a new one
@@ -260,22 +271,29 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
'base64' 'base64'
); );
} }
// First we need to attempt to log the user in to jellyfin // First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
let jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost.endsWith('/') const ip = req.ip;
? jellyfinHost.slice(0, -1) let clientIp;
: jellyfinHost;
if (ip) {
if (net.isIPv4(ip)) {
clientIp = ip;
} else if (net.isIPv6(ip)) {
clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
}
}
const ip = req.ip ? req.ip.split(':').reverse()[0] : undefined;
const account = await jellyfinserver.login( const account = await jellyfinserver.login(
body.username, body.username,
body.password, body.password,
ip clientIp
); );
// Next let's see if the user already exists // Next let's see if the user already exists
@@ -314,15 +332,21 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
userType: UserType.JELLYFIN, userType: UserType.JELLYFIN,
}); });
settings.jellyfin.hostname = body.hostname ?? ''; const serverName = await jellyfinserver.getServerName();
settings.jellyfin.name = serverName;
settings.jellyfin.serverId = account.User.ServerId; settings.jellyfin.serverId = account.User.ServerId;
settings.jellyfin.ip = body.hostname ?? '';
settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false;
settings.save(); settings.save();
startJobs(); startJobs();
await userRepository.save(user); await userRepository.save(user);
} }
// User already exists, let's update their information // User already exists, let's update their information
else if (body.username === user?.jellyfinUsername) { else if (account.User.Id === user?.jellyfinUserId) {
logger.info( logger.info(
`Found matching ${ `Found matching ${
settings.main.mediaServerType === MediaServerType.JELLYFIN settings.main.mediaServerType === MediaServerType.JELLYFIN
@@ -430,7 +454,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
label: 'Auth', label: 'Auth',
error: e.errorCode, error: e.errorCode,
status: e.statusCode, status: e.statusCode,
hostname: body.hostname, hostname: getHostname({
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
}),
} }
); );
return next({ return next({

View File

@@ -12,7 +12,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
try { try {
const collection = await tmdb.getCollection({ const collection = await tmdb.getCollection({
collectionId: Number(req.params.id), collectionId: Number(req.params.id),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

View File

@@ -166,7 +166,7 @@ discoverRoutes.get<{ language: string }>(
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
originalLanguage: req.params.language, originalLanguage: req.params.language,
}); });
@@ -211,7 +211,7 @@ discoverRoutes.get<{ genreId: string }>(
try { try {
const genres = await tmdb.getMovieGenres({ const genres = await tmdb.getMovieGenres({
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const genre = genres.find( const genre = genres.find(
@@ -224,7 +224,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
genre: req.params.genreId as string, genre: req.params.genreId as string,
}); });
@@ -272,7 +272,7 @@ discoverRoutes.get<{ studioId: string }>(
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
studio: req.params.studioId as string, studio: req.params.studioId as string,
}); });
@@ -322,7 +322,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
try { try {
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
primaryReleaseDateGte: date, primaryReleaseDateGte: date,
}); });
@@ -447,7 +447,7 @@ discoverRoutes.get<{ language: string }>(
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
originalLanguage: req.params.language, originalLanguage: req.params.language,
}); });
@@ -492,7 +492,7 @@ discoverRoutes.get<{ genreId: string }>(
try { try {
const genres = await tmdb.getTvGenres({ const genres = await tmdb.getTvGenres({
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const genre = genres.find( const genre = genres.find(
@@ -505,7 +505,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
genre: req.params.genreId, genre: req.params.genreId,
}); });
@@ -553,7 +553,7 @@ discoverRoutes.get<{ networkId: string }>(
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
network: Number(req.params.networkId), network: Number(req.params.networkId),
}); });
@@ -603,7 +603,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
try { try {
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
firstAirDateGte: date, firstAirDateGte: date,
}); });
@@ -643,7 +643,7 @@ discoverRoutes.get('/trending', async (req, res, next) => {
try { try {
const data = await tmdb.getAllTrending({ const data = await tmdb.getAllTrending({
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@@ -698,7 +698,7 @@ discoverRoutes.get<{ keywordId: string }>(
const data = await tmdb.getMoviesByKeyword({ const data = await tmdb.getMoviesByKeyword({
keywordId: Number(req.params.keywordId), keywordId: Number(req.params.keywordId),
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@@ -743,7 +743,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
const mappedGenres: GenreSliderItem[] = []; const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getMovieGenres({ const genres = await tmdb.getMovieGenres({
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
await Promise.all( await Promise.all(
@@ -787,7 +787,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
const mappedGenres: GenreSliderItem[] = []; const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getTvGenres({ const genres = await tmdb.getTvGenres({
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
await Promise.all( await Promise.all(

View File

@@ -237,7 +237,7 @@ router.get('/genres/movie', isAuthenticated(), async (req, res, next) => {
try { try {
const genres = await tmdb.getMovieGenres({ const genres = await tmdb.getMovieGenres({
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
return res.status(200).json(genres); return res.status(200).json(genres);
@@ -258,7 +258,7 @@ router.get('/genres/tv', isAuthenticated(), async (req, res, next) => {
try { try {
const genres = await tmdb.getTvGenres({ const genres = await tmdb.getTvGenres({
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
return res.status(200).json(genres); return res.status(200).json(genres);

View File

@@ -17,7 +17,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
try { try {
const tmdbMovie = await tmdb.getMovie({ const tmdbMovie = await tmdb.getMovie({
movieId: Number(req.params.id), movieId: Number(req.params.id),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
@@ -43,7 +43,7 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => {
const results = await tmdb.getMovieRecommendations({ const results = await tmdb.getMovieRecommendations({
movieId: Number(req.params.id), movieId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@@ -85,7 +85,7 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
const results = await tmdb.getMovieSimilar({ const results = await tmdb.getMovieSimilar({
movieId: Number(req.params.id), movieId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

View File

@@ -16,7 +16,7 @@ personRoutes.get('/:id', async (req, res, next) => {
try { try {
const person = await tmdb.getPerson({ const person = await tmdb.getPerson({
personId: Number(req.params.id), personId: Number(req.params.id),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
return res.status(200).json(mapPersonDetails(person)); return res.status(200).json(mapPersonDetails(person));
} catch (e) { } catch (e) {
@@ -38,7 +38,7 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
try { try {
const combinedCredits = await tmdb.getPersonCombinedCredits({ const combinedCredits = await tmdb.getPersonCombinedCredits({
personId: Number(req.params.id), personId: Number(req.params.id),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const castMedia = await Media.getRelatedMedia( const castMedia = await Media.getRelatedMedia(

View File

@@ -20,7 +20,7 @@ searchRoutes.get('/', async (req, res, next) => {
.match(searchProvider.pattern) as RegExpMatchArray; .match(searchProvider.pattern) as RegExpMatchArray;
results = await searchProvider.search({ results = await searchProvider.search({
id, id,
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
query: queryString, query: queryString,
}); });
} else { } else {
@@ -29,7 +29,7 @@ searchRoutes.get('/', async (req, res, next) => {
results = await tmdb.searchMulti({ results = await tmdb.searchMulti({
query: queryString, query: queryString,
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
} }

View File

@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
import PlexAPI from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli'; import TautulliAPI from '@server/api/tautulli';
import { ApiErrorCode } from '@server/constants/error';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest'; import { MediaRequest } from '@server/entity/MediaRequest';
@@ -24,8 +25,10 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover'; import discoverSettingRoutes from '@server/routes/settings/discover';
import { ApiError } from '@server/types/error';
import { appDataPath } from '@server/utils/appDataVolume'; import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import fs from 'fs'; import fs from 'fs';
@@ -252,11 +255,59 @@ settingsRoutes.get('/jellyfin', (_req, res) => {
res.status(200).json(settings.jellyfin); res.status(200).json(settings.jellyfin);
}); });
settingsRoutes.post('/jellyfin', (req, res) => { settingsRoutes.post('/jellyfin', async (req, res, next) => {
const userRepository = getRepository(User);
const settings = getSettings(); const settings = getSettings();
settings.jellyfin = merge(settings.jellyfin, req.body); try {
settings.save(); const admin = await userRepository.findOneOrFail({
where: { id: 1 },
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const tempJellyfinSettings = { ...settings.jellyfin, ...req.body };
const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings),
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
const result = await jellyfinClient.getSystemInfo();
if (!result?.Id) {
throw new ApiError(result?.status, ApiErrorCode.InvalidUrl);
}
Object.assign(settings.jellyfin, req.body);
settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName;
settings.save();
} catch (e) {
if (e instanceof ApiError) {
logger.error('Something went wrong testing Jellyfin connection', {
label: 'API',
status: e.statusCode,
errorMessage: ApiErrorCode.InvalidUrl,
});
return next({
status: e.statusCode,
message: ApiErrorCode.InvalidUrl,
});
} else {
logger.error('Something went wrong', {
label: 'API',
errorMessage: e.message,
});
return next({
status: e.statusCode ?? 500,
message: ApiErrorCode.Unknown,
});
}
}
return res.status(200).json(settings.jellyfin); return res.status(200).json(settings.jellyfin);
}); });
@@ -272,7 +323,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '', getHostname(),
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -288,10 +339,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
// Automatic Library grouping is not supported when user views are used to get library // Automatic Library grouping is not supported when user views are used to get library
if (account.Configuration.GroupedFolders.length > 0) { if (account.Configuration.GroupedFolders.length > 0) {
return next({ status: 501, message: 'SYNC_ERROR_GROUPED_FOLDERS' }); return next({
status: 501,
message: ApiErrorCode.SyncErrorGroupedFolders,
});
} }
return next({ status: 404, message: 'SYNC_ERROR_NO_LIBRARIES' }); return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries });
} }
const newLibraries: Library[] = libraries.map((library) => { const newLibraries: Library[] = libraries.map((library) => {
@@ -322,16 +376,12 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
}); });
settingsRoutes.get('/jellyfin/users', async (req, res) => { settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings(); const { externalHostname } = getSettings().jellyfin;
const { hostname, externalHostname } = getSettings().jellyfin; const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : getHostname();
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
@@ -339,7 +389,6 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );

View File

@@ -14,7 +14,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
try { try {
const tv = await tmdb.getTvShow({ const tv = await tmdb.getTvShow({
tvId: Number(req.params.id), tvId: Number(req.params.id),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const media = await Media.getMedia(tv.id, MediaType.TV); const media = await Media.getMedia(tv.id, MediaType.TV);
@@ -40,7 +40,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
const season = await tmdb.getTvSeason({ const season = await tmdb.getTvSeason({
tvId: Number(req.params.id), tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber), seasonNumber: Number(req.params.seasonNumber),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
return res.status(200).json(mapSeasonWithEpisodes(season)); return res.status(200).json(mapSeasonWithEpisodes(season));
@@ -65,7 +65,7 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => {
const results = await tmdb.getTvRecommendations({ const results = await tmdb.getTvRecommendations({
tvId: Number(req.params.id), tvId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@@ -106,7 +106,7 @@ tvRoutes.get('/:id/similar', async (req, res, next) => {
const results = await tmdb.getTvSimilar({ const results = await tmdb.getTvSimilar({
tvId: Number(req.params.id), tvId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: req.locale ?? (req.query.language as string), language: (req.query.language as string) ?? req.locale,
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

View File

@@ -20,6 +20,7 @@ import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash'; import { findIndex, sortBy } from 'lodash';
@@ -496,7 +497,6 @@ router.post(
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -504,15 +504,14 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers(); //const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = []; const createdUsers: User[] = [];
const { hostname, externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
let jellyfinHost = const hostname = getHostname();
const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers(); const jellyfinUsers = await jellyfinClient.getUsers();

View File

@@ -5,6 +5,7 @@
"module": "commonjs", "module": "commonjs",
"outDir": "../dist", "outDir": "../dist",
"noEmit": false, "noEmit": false,
"incremental": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@server/*": ["*"] "@server/*": ["*"]

View File

@@ -0,0 +1,18 @@
import { getSettings } from '@server/lib/settings';
interface HostnameParams {
useSsl?: boolean;
ip?: string;
port?: number;
urlBase?: string;
}
export const getHostname = (params?: HostnameParams): string => {
const settings = params ? params : getSettings().jellyfin;
const { useSsl, ip, port, urlBase } = settings;
const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`;
return hostname;
};

View File

@@ -12,7 +12,7 @@ confinement: strict
architectures: architectures:
- build-on: amd64 - build-on: amd64
- build-on: arm64 - build-on: arm64
- build-on: armhf # - build-on: armhf
parts: parts:
jellyseerr: jellyseerr:
@@ -27,12 +27,12 @@ parts:
- automake - automake
- python-gi - python-gi
- python-gi-dev - python-gi-dev
- on armhf: # - on armhf:
- libatomic1 # - libatomic1
- build-essential # - build-essential
- automake # - automake
- python-gi # - python-gi
- python-gi-dev # - python-gi-dev
source: . source: .
override-pull: | override-pull: |
snapcraftctl pull snapcraftctl pull
@@ -75,7 +75,7 @@ parts:
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
# Set Node.js version # Set Node.js version
NODE_MAJOR=18 NODE_MAJOR=20
# Add Node.js repository to sources list # Add Node.js repository to sources list
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
@@ -97,9 +97,9 @@ parts:
cp -R $SNAPCRAFT_PART_BUILD/node_modules $SNAPCRAFT_PART_INSTALL/ cp -R $SNAPCRAFT_PART_BUILD/node_modules $SNAPCRAFT_PART_INSTALL/
# Remove .github and gitbook as it will fail snap lint # Remove .github and gitbook as it will fail snap lint
rm -rf $SNAPCRAFT_PART_INSTALL/.github rm -rf $SNAPCRAFT_PART_INSTALL/.github
stage-packages: # stage-packages:
- on armhf: # - on armhf:
- libatomic1 # - libatomic1
stage: [.next, ./*] stage: [.next, ./*]
prime: [.next, ./*] prime: [.next, ./*]

View File

@@ -1,7 +1,8 @@
import Badge from '@app/components/Common/Badge'; import Badge from '@app/components/Common/Badge';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import defineMessages from '@app/utils/defineMessages';
import { FormattedRelativeTime, useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.AirDateBadge', {
airedrelative: 'Aired {relativeTime}', airedrelative: 'Aired {relativeTime}',
airsrelative: 'Airing {relativeTime}', airsrelative: 'Airing {relativeTime}',
}); });

View File

@@ -1,8 +1,9 @@
import Alert from '@app/components/Common/Alert'; import Alert from '@app/components/Common/Alert';
import { defineMessages, useIntl } from 'react-intl'; import defineMessages from '@app/utils/defineMessages';
import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.AppDataWarning', {
dockerVolumeMissingDescription: dockerVolumeMissingDescription:
'The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.', 'The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.',
}); });

View File

@@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'; import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
@@ -18,10 +19,10 @@ import { uniq } from 'lodash';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.CollectionDetails', {
overview: 'Overview', overview: 'Overview',
numberofmovies: '{count} Movies', numberofmovies: '{count} Movies',
requestcollection: 'Request Collection', requestcollection: 'Request Collection',
@@ -166,10 +167,9 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<Link <Link
href={`/discover/movies/genre/${genreId}`} href={`/discover/movies/genre/${genreId}`}
key={`genre-${genreId}`} key={`genre-${genreId}`}
className="hover:underline"
> >
<a className="hover:underline"> {genres.find((g) => g.id === genreId)?.name}
{genres.find((g) => g.id === genreId)?.name}
</a>
</Link> </Link>
)) ))
.reduce((prev, curr) => ( .reduce((prev, curr) => (
@@ -195,8 +195,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<CachedImage <CachedImage
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
layout="fill" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
objectFit="cover" fill
priority priority
/> />
<div <div
@@ -229,7 +229,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
: '/images/overseerr_poster_not_found.png' : '/images/overseerr_poster_not_found.png'
} }
alt="" alt=""
layout="responsive" sizes="100vw"
style={{ width: '100%', height: 'auto' }}
width={600} width={600}
height={900} height={900}
priority priority

View File

@@ -1,4 +1,3 @@
import type * as React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import AnimateHeight from 'react-animate-height'; import AnimateHeight from 'react-animate-height';

View File

@@ -93,13 +93,12 @@ const Badge = (
); );
} else if (href) { } else if (href) {
return ( return (
<Link href={href}> <Link
<a href={href}
className={badgeStyle.join(' ')} className={badgeStyle.join(' ')}
ref={ref as React.Ref<HTMLAnchorElement>} ref={ref as React.Ref<HTMLAnchorElement>}
> >
{children} {children}
</a>
</Link> </Link>
); );
} else { } else {

View File

@@ -64,8 +64,8 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
className="absolute inset-0 h-full w-full" className="absolute inset-0 h-full w-full"
alt="" alt=""
src={imageUrl} src={imageUrl}
layout="fill" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
objectFit="cover" fill
{...overrides} {...overrides}
/> />
<div <div

View File

@@ -125,8 +125,8 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<CachedImage <CachedImage
alt="" alt=""
src={backdrop} src={backdrop}
layout="fill" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
objectFit="cover" fill
priority priority
/> />
<div <div

View File

@@ -55,15 +55,14 @@ const SettingsLink = ({
} }
return ( return (
<Link href={route}> <Link
<a href={route}
className={`${linkClasses} ${ className={`${linkClasses} ${
currentPath.match(regex) ? activeLinkColor : inactiveLinkColor currentPath.match(regex) ? activeLinkColor : inactiveLinkColor
}`} }`}
aria-current="page" aria-current="page"
> >
{children} {children}
</a>
</Link> </Link>
); );
}; };

View File

@@ -12,40 +12,39 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
const [isHovered, setHovered] = useState(false); const [isHovered, setHovered] = useState(false);
return ( return (
<Link href={url}> <Link
<a href={url}
className={`relative flex h-32 w-56 transform-gpu cursor-pointer items-center justify-center p-8 shadow ring-1 transition duration-300 ease-in-out sm:h-36 sm:w-72 ${ className={`relative flex h-32 w-56 transform-gpu cursor-pointer items-center justify-center p-8 shadow ring-1 transition duration-300 ease-in-out sm:h-36 sm:w-72 ${
isHovered isHovered
? 'scale-105 bg-gray-700 ring-gray-500' ? 'scale-105 bg-gray-700 ring-gray-500'
: 'scale-100 bg-gray-800 ring-gray-700' : 'scale-100 bg-gray-800 ring-gray-700'
} rounded-xl`} } rounded-xl`}
onMouseEnter={() => { onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setHovered(true); setHovered(true);
}} }
onMouseLeave={() => setHovered(false)} }}
onKeyDown={(e) => { role="link"
if (e.key === 'Enter') { tabIndex={0}
setHovered(true); >
} <div className="relative h-full w-full">
}} <CachedImage
role="link" src={image}
tabIndex={0} alt={name}
> className="relative z-40 h-full w-full"
<div className="relative h-full w-full"> style={{ width: '100%', height: '100%', objectFit: 'contain' }}
<CachedImage fill
src={image}
alt={name}
className="relative z-40 h-full w-full"
layout="fill"
objectFit="contain"
/>
</div>
<div
className={`absolute bottom-0 left-0 right-0 z-0 h-12 rounded-b-xl bg-gradient-to-t ${
isHovered ? 'from-gray-800' : 'from-gray-900'
}`}
/> />
</a> </div>
<div
className={`absolute bottom-0 left-0 right-0 z-0 h-12 rounded-b-xl bg-gradient-to-t ${
isHovered ? 'from-gray-800' : 'from-gray-900'
}`}
/>
</Link> </Link>
); );
}; };

View File

@@ -4,6 +4,7 @@ import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider'; import MediaSlider from '@app/components/MediaSlider';
import { WatchProviderSelector } from '@app/components/Selector'; import { WatchProviderSelector } from '@app/components/Selector';
import { encodeURIExtraParams } from '@app/hooks/useDiscover'; import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import defineMessages from '@app/utils/defineMessages';
import type { import type {
TmdbCompanySearchResponse, TmdbCompanySearchResponse,
TmdbGenre, TmdbGenre,
@@ -16,12 +17,12 @@ import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import AsyncSelect from 'react-select/async'; import AsyncSelect from 'react-select/async';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup'; import * as Yup from 'yup';
const messages = defineMessages({ const messages = defineMessages('components.Discover.CreateSlider', {
addSlider: 'Add Slider', addSlider: 'Add Slider',
editSlider: 'Edit Slider', editSlider: 'Edit Slider',
slidernameplaceholder: 'Slider Name', slidernameplaceholder: 'Slider Name',

View File

@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { MovieResult } from '@server/models/Search'; import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverMovieGenre', {
genreMovies: '{genre} Movies', genreMovies: '{genre} Movies',
}); });

View File

@@ -4,12 +4,13 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover'; import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { MovieResult } from '@server/models/Search'; import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverMovieKeyword', {
keywordMovies: '{keywordTitle} Movies', keywordMovies: '{keywordTitle} Movies',
}); });

View File

@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { MovieResult } from '@server/models/Search'; import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverMovieLanguage', {
languageMovies: '{language} Movies', languageMovies: '{language} Movies',
}); });

View File

@@ -11,14 +11,15 @@ import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { MovieResult } from '@server/models/Search'; import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverMovies', {
discovermovies: 'Movies', discovermovies: 'Movies',
activefilters: activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}', '{count, plural, one {# Active Filter} other {# Active Filters}}',

View File

@@ -4,12 +4,14 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TvNetwork } from '@server/models/common'; import type { TvNetwork } from '@server/models/common';
import type { TvResult } from '@server/models/Search'; import type { TvResult } from '@server/models/Search';
import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverNetwork', {
networkSeries: '{network} Series', networkSeries: '{network} Series',
}); });
@@ -47,10 +49,11 @@ const DiscoverTvNetwork = () => {
<Header> <Header>
{firstResultData?.network.logoPath ? ( {firstResultData?.network.logoPath ? (
<div className="mb-6 flex justify-center"> <div className="mb-6 flex justify-center">
<img <Image
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`} src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
alt={firstResultData.network.name} alt={firstResultData.network.name}
className="max-h-24 sm:max-h-32" className="max-h-24 sm:max-h-32"
fill
/> />
</div> </div>
) : ( ) : (

View File

@@ -8,6 +8,7 @@ import CreateSlider from '@app/components/Discover/CreateSlider';
import GenreTag from '@app/components/GenreTag'; import GenreTag from '@app/components/GenreTag';
import KeywordTag from '@app/components/KeywordTag'; import KeywordTag from '@app/components/KeywordTag';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { import {
ArrowUturnLeftIcon, ArrowUturnLeftIcon,
@@ -22,10 +23,10 @@ import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios'; import axios from 'axios';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-aria'; import { useDrag, useDrop } from 'react-aria';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverSliderEdit', {
deletesuccess: 'Sucessfully deleted slider.', deletesuccess: 'Sucessfully deleted slider.',
deletefail: 'Failed to delete slider.', deletefail: 'Failed to delete slider.',
remove: 'Remove', remove: 'Remove',

View File

@@ -4,12 +4,14 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { ProductionCompany } from '@server/models/common'; import type { ProductionCompany } from '@server/models/common';
import type { MovieResult } from '@server/models/Search'; import type { MovieResult } from '@server/models/Search';
import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverStudio', {
studioMovies: '{studio} Movies', studioMovies: '{studio} Movies',
}); });
@@ -47,10 +49,11 @@ const DiscoverMovieStudio = () => {
<Header> <Header>
{firstResultData?.studio.logoPath ? ( {firstResultData?.studio.logoPath ? (
<div className="mb-6 flex justify-center"> <div className="mb-6 flex justify-center">
<img <Image
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`} src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
alt={firstResultData.studio.name} alt={firstResultData.studio.name}
className="max-h-24 sm:max-h-32" className="max-h-24 sm:max-h-32"
fill
/> />
</div> </div>
) : ( ) : (

View File

@@ -11,14 +11,15 @@ import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { TvResult } from '@server/models/Search'; import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverTv', {
discovertv: 'Series', discovertv: 'Series',
activefilters: activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}', '{count, plural, one {# Active Filter} other {# Active Filters}}',

View File

@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TvResult } from '@server/models/Search'; import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverTvGenre', {
genreSeries: '{genre} Series', genreSeries: '{genre} Series',
}); });

View File

@@ -4,12 +4,13 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover'; import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search'; import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverTvKeyword', {
keywordSeries: '{keywordTitle} Series', keywordSeries: '{keywordTitle} Series',
}); });

View File

@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TvResult } from '@server/models/Search'; import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverTvLanguage', {
languageSeries: '{language} Series', languageSeries: '{language} Series',
}); });

View File

@@ -3,12 +3,11 @@ import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TvResult } from '@server/models/Search'; import type { TvResult } from '@server/models/Search';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.DiscoverTvUpcoming', {});
upcomingtv: 'Upcoming Series',
});
const DiscoverTvUpcoming = () => { const DiscoverTvUpcoming = () => {
const intl = useIntl(); const intl = useIntl();

View File

@@ -4,12 +4,13 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.DiscoverWatchlist', {
discoverwatchlist: 'Your Watchlist', discoverwatchlist: 'Your Watchlist',
watchlist: 'Plex Watchlist', watchlist: 'Plex Watchlist',
}); });
@@ -58,8 +59,8 @@ const DiscoverWatchlist = () => {
<Header <Header
subtext={ subtext={
router.query.userId ? ( router.query.userId ? (
<Link href={`/users/${user?.id}`}> <Link href={`/users/${user?.id}`} className="hover:underline">
<a className="hover:underline">{user?.displayName}</a> {user?.displayName}
</Link> </Link>
) : ( ) : (
'' ''

View File

@@ -15,11 +15,12 @@ import {
useBatchUpdateQueryParams, useBatchUpdateQueryParams,
useUpdateQueryParams, useUpdateQueryParams,
} from '@app/hooks/useUpdateQueryParams'; } from '@app/hooks/useUpdateQueryParams';
import defineMessages from '@app/utils/defineMessages';
import { XCircleIcon } from '@heroicons/react/24/outline'; import { XCircleIcon } from '@heroicons/react/24/outline';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import Datepicker from 'react-tailwindcss-datepicker-sct'; import Datepicker from 'react-tailwindcss-datepicker-sct';
const messages = defineMessages({ const messages = defineMessages('components.Discover.FilterSlideover', {
filters: 'Filters', filters: 'Filters',
activefilters: activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}', '{count, plural, one {# Active Filter} other {# Active Filters}}',

View File

@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import { genreColorMap } from '@app/components/Discover/constants'; import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard'; import GenreCard from '@app/components/GenreCard';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.Discover.MovieGenreList', {
moviegenres: 'Movie Genres', moviegenres: 'Movie Genres',
}); });

View File

@@ -1,14 +1,15 @@
import { genreColorMap } from '@app/components/Discover/constants'; import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard'; import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import defineMessages from '@app/utils/defineMessages';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link'; import Link from 'next/link';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.Discover.MovieGenreSlider', {
moviegenres: 'Movie Genres', moviegenres: 'Movie Genres',
}); });
@@ -25,11 +26,9 @@ const MovieGenreSlider = () => {
return ( return (
<> <>
<div className="slider-header"> <div className="slider-header">
<Link href="/discover/movies/genres"> <Link href="/discover/movies/genres" className="slider-title">
<a className="slider-title"> <span>{intl.formatMessage(messages.moviegenres)}</span>
<span>{intl.formatMessage(messages.moviegenres)}</span> <ArrowRightCircleIcon />
<ArrowRightCircleIcon />
</a>
</Link> </Link>
</div> </div>
<Slider <Slider

View File

@@ -1,8 +1,9 @@
import CompanyCard from '@app/components/CompanyCard'; import CompanyCard from '@app/components/CompanyCard';
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import { defineMessages, useIntl } from 'react-intl'; import defineMessages from '@app/utils/defineMessages';
import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.NetworkSlider', {
networks: 'Networks', networks: 'Networks',
}); });

View File

@@ -1,13 +1,14 @@
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link'; import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.Discover.PlexWatchlistSlider', {
plexwatchlist: 'Your Watchlist', plexwatchlist: 'Your Watchlist',
emptywatchlist: emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.', 'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
@@ -39,11 +40,9 @@ const PlexWatchlistSlider = () => {
return ( return (
<> <>
<div className="slider-header"> <div className="slider-header">
<Link href="/discover/watchlist"> <Link href="/discover/watchlist" className="slider-title">
<a className="slider-title"> <span>{intl.formatMessage(messages.plexwatchlist)}</span>
<span>{intl.formatMessage(messages.plexwatchlist)}</span> <ArrowRightCircleIcon />
<ArrowRightCircleIcon />
</a>
</Link> </Link>
</div> </div>
<Slider <Slider

View File

@@ -24,11 +24,9 @@ const RecentRequestsSlider = () => {
return ( return (
<> <>
<div className="slider-header"> <div className="slider-header">
<Link href="/requests?filter=all"> <Link href="/requests?filter=all" className="slider-title">
<a className="slider-title"> <span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span> <ArrowRightCircleIcon />
<ArrowRightCircleIcon />
</a>
</Link> </Link>
</div> </div>
<Slider <Slider

View File

@@ -1,11 +1,12 @@
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces'; import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.Discover.RecentlyAddedSlider', {
recentlyAdded: 'Recently Added', recentlyAdded: 'Recently Added',
}); });

View File

@@ -1,8 +1,9 @@
import CompanyCard from '@app/components/CompanyCard'; import CompanyCard from '@app/components/CompanyCard';
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import { defineMessages, useIntl } from 'react-intl'; import defineMessages from '@app/utils/defineMessages';
import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover.StudioSlider', {
studios: 'Studios', studios: 'Studios',
}); });

View File

@@ -3,14 +3,15 @@ import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { import type {
MovieResult, MovieResult,
PersonResult, PersonResult,
TvResult, TvResult,
} from '@server/models/Search'; } from '@server/models/Search';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover', {
trending: 'Trending', trending: 'Trending',
}); });

View File

@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import { genreColorMap } from '@app/components/Discover/constants'; import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard'; import GenreCard from '@app/components/GenreCard';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.Discover.TvGenreList', {
seriesgenres: 'Series Genres', seriesgenres: 'Series Genres',
}); });

View File

@@ -1,14 +1,15 @@
import { genreColorMap } from '@app/components/Discover/constants'; import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard'; import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import defineMessages from '@app/utils/defineMessages';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link'; import Link from 'next/link';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.Discover.TvGenreSlider', {
tvgenres: 'Series Genres', tvgenres: 'Series Genres',
}); });
@@ -25,11 +26,9 @@ const TvGenreSlider = () => {
return ( return (
<> <>
<div className="slider-header"> <div className="slider-header">
<Link href="/discover/tv/genres"> <Link href="/discover/tv/genres" className="slider-title">
<a className="slider-title"> <span>{intl.formatMessage(messages.tvgenres)}</span>
<span>{intl.formatMessage(messages.tvgenres)}</span> <ArrowRightCircleIcon />
<ArrowRightCircleIcon />
</a>
</Link> </Link>
</div> </div>
<Slider <Slider

View File

@@ -3,10 +3,11 @@ import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { MovieResult } from '@server/models/Search'; import type { MovieResult } from '@server/models/Search';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Discover', {
upcomingmovies: 'Upcoming Movies', upcomingmovies: 'Upcoming Movies',
}); });

View File

@@ -1,5 +1,5 @@
import defineMessages from '@app/utils/defineMessages';
import type { ParsedUrlQuery } from 'querystring'; import type { ParsedUrlQuery } from 'querystring';
import { defineMessages } from 'react-intl';
import { z } from 'zod'; import { z } from 'zod';
type AvailableColors = type AvailableColors =
@@ -66,7 +66,7 @@ export const genreColorMap: Record<number, [string, string]> = {
10768: colorTones.darkred, // War & Politics 10768: colorTones.darkred, // War & Politics
}; };
export const sliderTitles = defineMessages({ export const sliderTitles = defineMessages('components.Discover', {
recentrequests: 'Recent Requests', recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies', popularmovies: 'Popular Movies',
populartv: 'Popular Series', populartv: 'Popular Series',

View File

@@ -17,6 +17,7 @@ import MediaSlider from '@app/components/MediaSlider';
import { encodeURIExtraParams } from '@app/hooks/useDiscover'; import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { import {
ArrowDownOnSquareIcon, ArrowDownOnSquareIcon,
@@ -29,11 +30,11 @@ import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios'; import axios from 'axios';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.Discover', {
discover: 'Discover', discover: 'Discover',
emptywatchlist: emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.', 'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',

View File

@@ -1,9 +1,10 @@
import Badge from '@app/components/Common/Badge'; import Badge from '@app/components/Common/Badge';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import type { DownloadingItem } from '@server/lib/downloadtracker'; import type { DownloadingItem } from '@server/lib/downloadtracker';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { FormattedRelativeTime, useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.DownloadBlock', {
estimatedtime: 'Estimated {time}', estimatedtime: 'Estimated {time}',
formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}', formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}',
}); });

View File

@@ -14,37 +14,41 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
const [isHovered, setHovered] = useState(false); const [isHovered, setHovered] = useState(false);
return ( return (
<Link href={url}> <Link
<a href={url}
className={`relative flex h-32 items-center justify-center sm:h-36 ${ className={`relative flex h-32 items-center justify-center sm:h-36 ${
canExpand ? 'w-full' : 'w-56 sm:w-72' canExpand ? 'w-full' : 'w-56 sm:w-72'
} transform-gpu cursor-pointer p-8 shadow ring-1 transition duration-300 ease-in-out ${ } transform-gpu cursor-pointer p-8 shadow ring-1 transition duration-300 ease-in-out ${
isHovered isHovered
? 'scale-105 bg-gray-700 bg-opacity-100 ring-gray-500' ? 'scale-105 bg-gray-700 bg-opacity-100 ring-gray-500'
: 'scale-100 bg-gray-800 bg-opacity-80 ring-gray-700' : 'scale-100 bg-gray-800 bg-opacity-80 ring-gray-700'
} overflow-hidden rounded-xl bg-cover bg-center`} } overflow-hidden rounded-xl bg-cover bg-center`}
onMouseEnter={() => { onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setHovered(true); setHovered(true);
}} }
onMouseLeave={() => setHovered(false)} }}
onKeyDown={(e) => { role="link"
if (e.key === 'Enter') { tabIndex={0}
setHovered(true); >
} <CachedImage
}} src={image}
role="link" alt=""
tabIndex={0} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
> fill
<CachedImage src={image} alt="" layout="fill" objectFit="cover" /> />
<div <div
className={`absolute inset-0 z-10 h-full w-full bg-gray-800 transition duration-300 ${ className={`absolute inset-0 z-10 h-full w-full bg-gray-800 transition duration-300 ${
isHovered ? 'bg-opacity-10' : 'bg-opacity-30' isHovered ? 'bg-opacity-10' : 'bg-opacity-30'
}`} }`}
/> />
<div className="relative z-20 w-full truncate whitespace-normal text-center text-2xl font-bold text-white sm:text-3xl"> <div className="relative z-20 w-full truncate whitespace-normal text-center text-2xl font-bold text-white sm:text-3xl">
{name} {name}
</div> </div>
</a>
</Link> </Link>
); );
}; };

View File

@@ -45,10 +45,9 @@ const IssueBlock = ({ issue }: IssueBlockProps) => {
? '/profile' ? '/profile'
: `/users/${issue.createdBy.id}` : `/users/${issue.createdBy.id}`
} }
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
> >
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"> {issue.createdBy.displayName}
{issue.createdBy.displayName}
</a>
</Link> </Link>
</span> </span>
</div> </div>
@@ -64,7 +63,7 @@ const IssueBlock = ({ issue }: IssueBlockProps) => {
</div> </div>
</div> </div>
<div className="ml-2 flex flex-shrink-0 flex-wrap"> <div className="ml-2 flex flex-shrink-0 flex-wrap">
<Link href={`/issues/${issue.id}`} passHref> <Link href={`/issues/${issue.id}`} passHref legacyBehavior>
<Button buttonType="primary" as="a"> <Button buttonType="primary" as="a">
<EyeIcon /> <EyeIcon />
</Button> </Button>

View File

@@ -1,18 +1,20 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react'; import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment'; import type { default as IssueCommentType } from '@server/entity/IssueComment';
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { FormattedRelativeTime, useIntl } from 'react-intl';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import * as Yup from 'yup'; import * as Yup from 'yup';
const messages = defineMessages({ const messages = defineMessages('components.IssueDetails.IssueComment', {
postedby: 'Posted {relativeTime} by {username}', postedby: 'Posted {relativeTime} by {username}',
postedbyedited: 'Posted {relativeTime} by {username} (Edited)', postedbyedited: 'Posted {relativeTime} by {username} (Edited)',
delete: 'Delete Comment', delete: 'Delete Comment',
@@ -84,13 +86,13 @@ const IssueComment = ({
</Modal> </Modal>
</Transition> </Transition>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}> <Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<a> <Image
<img src={comment.user.avatar}
src={comment.user.avatar} alt=""
alt="" className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105" width={40}
/> height={40}
</a> />
</Link> </Link>
<div className="relative flex-1"> <div className="relative flex-1">
<div className="w-full rounded-md shadow ring-1 ring-gray-500"> <div className="w-full rounded-md shadow ring-1 ring-gray-500">
@@ -242,10 +244,9 @@ const IssueComment = ({
href={ href={
isActiveUser ? '/profile' : `/users/${comment.user.id}` isActiveUser ? '/profile' : `/users/${comment.user.id}`
} }
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
> >
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"> {comment.user.displayName}
{comment.user.displayName}
</a>
</Link> </Link>
), ),
relativeTime: ( relativeTime: (

View File

@@ -1,14 +1,15 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react'; import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useState } from 'react'; import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
const messages = defineMessages({ const messages = defineMessages('components.IssueDetails.IssueDescription', {
description: 'Description', description: 'Description',
edit: 'Edit Description', edit: 'Edit Description',
deleteissue: 'Delete Issue', deleteissue: 'Delete Issue',

View File

@@ -12,6 +12,7 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { import {
ChatBubbleOvalLeftEllipsisIcon, ChatBubbleOvalLeftEllipsisIcon,
@@ -29,15 +30,16 @@ import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config'; import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import * as Yup from 'yup'; import * as Yup from 'yup';
const messages = defineMessages({ const messages = defineMessages('components.IssueDetails', {
openedby: '#{issueId} opened {relativeTime} by {username}', openedby: '#{issueId} opened {relativeTime} by {username}',
closeissue: 'Close Issue', closeissue: 'Close Issue',
closeissueandcomment: 'Close with Comment', closeissueandcomment: 'Close with Comment',
@@ -210,8 +212,8 @@ const IssueDetails = () => {
<CachedImage <CachedImage
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
layout="fill" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
objectFit="cover" fill
priority priority
/> />
<div <div
@@ -232,7 +234,8 @@ const IssueDetails = () => {
: '/images/overseerr_poster_not_found.png' : '/images/overseerr_poster_not_found.png'
} }
alt="" alt=""
layout="responsive" sizes="100vw"
style={{ width: '100%', height: 'auto' }}
width={600} width={600}
height={900} height={900}
priority priority
@@ -256,8 +259,9 @@ const IssueDetails = () => {
href={`/${ href={`/${
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv' issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
}/${data.id}`} }/${data.id}`}
className="hover:underline"
> >
<a className="hover:underline">{title}</a> {title}
</Link>{' '} </Link>{' '}
{releaseYear && ( {releaseYear && (
<span className="media-year">({releaseYear.slice(0, 4)})</span> <span className="media-year">({releaseYear.slice(0, 4)})</span>
@@ -273,17 +277,18 @@ const IssueDetails = () => {
? '/profile' ? '/profile'
: `/users/${issueData.createdBy.id}` : `/users/${issueData.createdBy.id}`
} }
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
> >
<a className="group ml-1 inline-flex h-full items-center xl:ml-1.5"> <Image
<img className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6" src={issueData.createdBy.avatar}
src={issueData.createdBy.avatar} alt=""
alt="" width={20}
/> height={20}
<span className="font-semibold text-gray-100 transition duration-300 group-hover:text-white group-hover:underline"> />
{issueData.createdBy.displayName} <span className="font-semibold text-gray-100 transition duration-300 group-hover:text-white group-hover:underline">
</span> {issueData.createdBy.displayName}
</a> </span>
</Link> </Link>
), ),
relativeTime: ( relativeTime: (

View File

@@ -4,18 +4,20 @@ import CachedImage from '@app/components/Common/CachedImage';
import { issueOptions } from '@app/components/IssueModal/constants'; import { issueOptions } from '@app/components/IssueModal/constants';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { EyeIcon } from '@heroicons/react/24/solid'; import { EyeIcon } from '@heroicons/react/24/solid';
import { IssueStatus } from '@server/constants/issue'; import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { FormattedRelativeTime, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.IssueList.IssueItem', {
openeduserdate: '{date} by {user}', openeduserdate: '{date} by {user}',
seasons: '{seasonCount, plural, one {Season} other {Seasons}}', seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
episodes: '{episodeCount, plural, one {Episode} other {Episodes}}', episodes: '{episodeCount, plural, one {Episode} other {Episodes}}',
@@ -113,8 +115,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
<CachedImage <CachedImage
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt="" alt=""
layout="fill" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
objectFit="cover" fill
/> />
<div <div
className="absolute inset-0" className="absolute inset-0"
@@ -133,21 +135,20 @@ const IssueItem = ({ issue }: IssueItemProps) => {
? `/movie/${issue.media.tmdbId}` ? `/movie/${issue.media.tmdbId}`
: `/tv/${issue.media.tmdbId}` : `/tv/${issue.media.tmdbId}`
} }
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
> >
<a className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"> <CachedImage
<CachedImage src={
src={ title.posterPath
title.posterPath ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` : '/images/overseerr_poster_not_found.png'
: '/images/overseerr_poster_not_found.png' }
} alt=""
alt="" sizes="100vw"
layout="responsive" style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
width={600} width={600}
height={900} height={900}
objectFit="cover" />
/>
</a>
</Link> </Link>
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4"> <div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div className="pt-0.5 text-xs text-white sm:pt-1"> <div className="pt-0.5 text-xs text-white sm:pt-1">
@@ -162,10 +163,9 @@ const IssueItem = ({ issue }: IssueItemProps) => {
? `/movie/${issue.media.tmdbId}` ? `/movie/${issue.media.tmdbId}`
: `/tv/${issue.media.tmdbId}` : `/tv/${issue.media.tmdbId}`
} }
className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"
> >
<a className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"> {isMovie(title) ? title.title : title.name}
{isMovie(title) ? title.title : title.name}
</a>
</Link> </Link>
{problemSeasonEpisodeLine.length > 0 && ( {problemSeasonEpisodeLine.length > 0 && (
<div className="card-field"> <div className="card-field">
@@ -222,17 +222,20 @@ const IssueItem = ({ issue }: IssueItemProps) => {
/> />
), ),
user: ( user: (
<Link href={`/users/${issue.createdBy.id}`}> <Link
<a className="group flex items-center truncate"> href={`/users/${issue.createdBy.id}`}
<img className="group flex items-center truncate"
src={issue.createdBy.avatar} >
alt="" <Image
className="avatar-sm ml-1.5 object-cover" src={issue.createdBy.avatar}
/> alt=""
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline"> className="avatar-sm ml-1.5 object-cover"
{issue.createdBy.displayName} width={20}
</span> height={20}
</a> />
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{issue.createdBy.displayName}
</span>
</Link> </Link>
), ),
})} })}
@@ -259,7 +262,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
</div> </div>
<div className="z-10 mt-4 flex w-full flex-col justify-center pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0"> <div className="z-10 mt-4 flex w-full flex-col justify-center pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
<span className="w-full"> <span className="w-full">
<Link href={`/issues/${issue.id}`} passHref> <Link href={`/issues/${issue.id}`} passHref legacyBehavior>
<Button as="a" className="w-full" buttonType="primary"> <Button as="a" className="w-full" buttonType="primary">
<EyeIcon /> <EyeIcon />
<span>{intl.formatMessage(messages.viewissue)}</span> <span>{intl.formatMessage(messages.viewissue)}</span>

View File

@@ -5,6 +5,7 @@ import PageTitle from '@app/components/Common/PageTitle';
import IssueItem from '@app/components/IssueList/IssueItem'; import IssueItem from '@app/components/IssueList/IssueItem';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { import {
BarsArrowDownIcon, BarsArrowDownIcon,
ChevronLeftIcon, ChevronLeftIcon,
@@ -14,10 +15,10 @@ import {
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces'; import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.IssueList', {
issues: 'Issues', issues: 'Issues',
sortAdded: 'Most Recent', sortAdded: 'Most Recent',
sortModified: 'Last Modified', sortModified: 'Last Modified',

View File

@@ -4,6 +4,7 @@ import { issueOptions } from '@app/components/IssueModal/constants';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { RadioGroup } from '@headlessui/react'; import { RadioGroup } from '@headlessui/react';
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid'; import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
@@ -13,12 +14,12 @@ import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import Link from 'next/link'; import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import * as Yup from 'yup'; import * as Yup from 'yup';
const messages = defineMessages({ const messages = defineMessages('components.IssueModal.CreateIssueModal', {
validationMessageRequired: 'You must provide a description', validationMessageRequired: 'You must provide a description',
whatswrong: "What's wrong?", whatswrong: "What's wrong?",
providedetail: providedetail:
@@ -118,7 +119,7 @@ const CreateIssueModal = ({
strong: (msg: React.ReactNode) => <strong>{msg}</strong>, strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})} })}
</div> </div>
<Link href={`/issues/${newIssue.data.id}`}> <Link href={`/issues/${newIssue.data.id}`} legacyBehavior>
<Button as="a" className="mt-4"> <Button as="a" className="mt-4">
<span>{intl.formatMessage(messages.toastviewissue)}</span> <span>{intl.formatMessage(messages.toastviewissue)}</span>
<ArrowRightCircleIcon /> <ArrowRightCircleIcon />

View File

@@ -1,8 +1,8 @@
import defineMessages from '@app/utils/defineMessages';
import { IssueType } from '@server/constants/issue'; import { IssueType } from '@server/constants/issue';
import type { MessageDescriptor } from 'react-intl'; import type { MessageDescriptor } from 'react-intl';
import { defineMessages } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.IssueModal', {
issueAudio: 'Audio', issueAudio: 'Audio',
issueVideo: 'Video', issueVideo: 'Video',
issueSubtitles: 'Subtitle', issueSubtitles: 'Subtitle',

View File

@@ -1,13 +1,14 @@
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import type { Language } from '@server/lib/settings'; import type { Language } from '@server/lib/settings';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import type { CSSObjectWithLabel } from 'react-select'; import type { CSSObjectWithLabel } from 'react-select';
import Select from 'react-select'; import Select from 'react-select';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.LanguageSelector', {
originalLanguageDefault: 'All Languages', originalLanguageDefault: 'All Languages',
languageServerDefault: 'Default ({language})', languageServerDefault: 'Default ({language})',
}); });

View File

@@ -2,12 +2,13 @@ import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext'; import { availableLanguages } from '@app/context/LanguageContext';
import useClickOutside from '@app/hooks/useClickOutside'; import useClickOutside from '@app/hooks/useClickOutside';
import useLocale from '@app/hooks/useLocale'; import useLocale from '@app/hooks/useLocale';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { LanguageIcon } from '@heroicons/react/24/solid'; import { LanguageIcon } from '@heroicons/react/24/solid';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Layout.LanguagePicker', {
displaylanguage: 'Display Language', displaylanguage: 'Display Language',
}); });

View File

@@ -142,25 +142,25 @@ const MobileMenu = () => {
{filteredLinks.map((link) => { {filteredLinks.map((link) => {
const isActive = router.pathname.match(link.activeRegExp); const isActive = router.pathname.match(link.activeRegExp);
return ( return (
<Link key={`mobile-menu-link-${link.href}`} href={link.href}> <Link
<a key={`mobile-menu-link-${link.href}`}
className={`flex items-center space-x-2 ${ href={link.href}
isActive ? 'text-indigo-500' : '' className={`flex items-center space-x-2 ${
}`} isActive ? 'text-indigo-500' : ''
onKeyDown={(e) => { }`}
if (e.key === 'Enter') { onKeyDown={(e) => {
setIsOpen(false); if (e.key === 'Enter') {
} setIsOpen(false);
}} }
onClick={() => setIsOpen(false)} }}
role="button" onClick={() => setIsOpen(false)}
tabIndex={0} role="button"
> tabIndex={0}
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, { >
className: 'h-5 w-5', {cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
})} className: 'h-5 w-5',
<span>{link.content}</span> })}
</a> <span>{link.content}</span>
</Link> </Link>
); );
})} })}
@@ -173,19 +173,19 @@ const MobileMenu = () => {
const isActive = const isActive =
router.pathname.match(link.activeRegExp) && !isOpen; router.pathname.match(link.activeRegExp) && !isOpen;
return ( return (
<Link key={`mobile-menu-link-${link.href}`} href={link.href}> <Link
<a key={`mobile-menu-link-${link.href}`}
className={`flex flex-col items-center space-y-1 ${ href={link.href}
isActive ? 'text-indigo-500' : '' className={`flex flex-col items-center space-y-1 ${
}`} isActive ? 'text-indigo-500' : ''
> }`}
{cloneElement( >
isActive ? link.svgIconSelected : link.svgIcon, {cloneElement(
{ isActive ? link.svgIconSelected : link.svgIcon,
className: 'h-6 w-6', {
} className: 'h-6 w-6',
)} }
</a> )}
</Link> </Link>
); );
})} })}

View File

@@ -1,9 +1,10 @@
import useSearchInput from '@app/hooks/useSearchInput'; import useSearchInput from '@app/hooks/useSearchInput';
import defineMessages from '@app/utils/defineMessages';
import { XCircleIcon } from '@heroicons/react/24/outline'; import { XCircleIcon } from '@heroicons/react/24/outline';
import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'; import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Layout.SearchInput', {
searchPlaceholder: 'Search Movies & TV', searchPlaceholder: 'Search Movies & TV',
}); });

View File

@@ -2,6 +2,7 @@ import UserWarnings from '@app/components/Layout/UserWarnings';
import VersionStatus from '@app/components/Layout/VersionStatus'; import VersionStatus from '@app/components/Layout/VersionStatus';
import useClickOutside from '@app/hooks/useClickOutside'; import useClickOutside from '@app/hooks/useClickOutside';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { import {
ClockIcon, ClockIcon,
@@ -13,12 +14,13 @@ import {
UsersIcon, UsersIcon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Fragment, useRef } from 'react'; import { Fragment, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
export const menuMessages = defineMessages({ export const menuMessages = defineMessages('components.Layout.Sidebar', {
dashboard: 'Discover', dashboard: 'Discover',
browsemovies: 'Movies', browsemovies: 'Movies',
browsetv: 'Series', browsetv: 'Series',
@@ -146,16 +148,16 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
</div> </div>
<div <div
ref={navRef} ref={navRef}
className="flex flex-1 flex-col overflow-y-auto pt-8 pb-8 sm:pb-4" className="flex flex-1 flex-col overflow-y-auto pt-4 pb-8 sm:pb-4"
> >
<div className="flex flex-shrink-0 items-center px-2"> <div className="flex flex-shrink-0 items-center px-2">
<span className="px-4 text-xl text-gray-50"> <span className="w-full px-4 text-xl text-gray-50">
<a href="/"> <Link href="/" className="relative block h-24 w-64">
<img src="/logo_full.svg" alt="Logo" /> <Image src="/logo_full.svg" alt="Logo" fill />
</a> </Link>
</span> </span>
</div> </div>
<nav className="mt-16 flex-1 space-y-4 px-4"> <nav className="mt-10 flex-1 space-y-4 px-4">
{SidebarLinks.filter((link) => {SidebarLinks.filter((link) =>
link.requiredPermission link.requiredPermission
? hasPermission(link.requiredPermission, { ? hasPermission(link.requiredPermission, {
@@ -168,32 +170,27 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
key={`mobile-${sidebarLink.messagesKey}`} key={`mobile-${sidebarLink.messagesKey}`}
href={sidebarLink.href} href={sidebarLink.href}
as={sidebarLink.as} as={sidebarLink.as}
onClick={() => setClosed()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setClosed();
}
}}
role="button"
tabIndex={0}
className={`flex items-center rounded-md px-2 py-2 text-base font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
${
router.pathname.match(sidebarLink.activeRegExp)
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
data-testid={`${sidebarLink.dataTestId}-mobile`}
> >
<a {sidebarLink.svgIcon}
onClick={() => setClosed()} {intl.formatMessage(
onKeyDown={(e) => { menuMessages[sidebarLink.messagesKey]
if (e.key === 'Enter') { )}
setClosed();
}
}}
role="button"
tabIndex={0}
className={`flex items-center rounded-md px-2 py-2 text-base font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
${
router.pathname.match(
sidebarLink.activeRegExp
)
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
data-testid={`${sidebarLink.dataTestId}-mobile`}
>
{sidebarLink.svgIcon}
{intl.formatMessage(
menuMessages[sidebarLink.messagesKey]
)}
</a>
</Link> </Link>
); );
})} })}
@@ -221,15 +218,15 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
<div className="fixed top-0 bottom-0 left-0 z-30 hidden lg:flex lg:flex-shrink-0"> <div className="fixed top-0 bottom-0 left-0 z-30 hidden lg:flex lg:flex-shrink-0">
<div className="sidebar flex w-64 flex-col"> <div className="sidebar flex w-64 flex-col">
<div className="flex h-0 flex-1 flex-col"> <div className="flex h-0 flex-1 flex-col">
<div className="flex flex-1 flex-col overflow-y-auto pt-8 pb-4"> <div className="flex flex-1 flex-col overflow-y-auto pb-4">
<div className="flex flex-shrink-0 items-center"> <div className="flex flex-shrink-0 items-center">
<span className="px-4 text-2xl text-gray-50"> <span className="w-full px-4 py-2 text-2xl text-gray-50">
<a href="/"> <Link href="/" className="relative block h-24">
<img src="/logo_full.svg" alt="Logo" /> <Image src="/logo_full.svg" alt="Logo" fill />
</a> </Link>
</span> </span>
</div> </div>
<nav className="mt-16 flex-1 space-y-4 px-4"> <nav className="mt-8 flex-1 space-y-4 px-4">
{SidebarLinks.filter((link) => {SidebarLinks.filter((link) =>
link.requiredPermission link.requiredPermission
? hasPermission(link.requiredPermission, { ? hasPermission(link.requiredPermission, {
@@ -242,24 +239,19 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
key={`desktop-${sidebarLink.messagesKey}`} key={`desktop-${sidebarLink.messagesKey}`}
href={sidebarLink.href} href={sidebarLink.href}
as={sidebarLink.as} as={sidebarLink.as}
className={`group flex items-center rounded-md px-2 py-2 text-lg font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
${
router.pathname.match(sidebarLink.activeRegExp)
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
data-testid={sidebarLink.dataTestId}
> >
<a {sidebarLink.svgIcon}
className={`group flex items-center rounded-md px-2 py-2 text-lg font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none {intl.formatMessage(
${ menuMessages[sidebarLink.messagesKey]
router.pathname.match( )}
sidebarLink.activeRegExp
)
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
data-testid={sidebarLink.dataTestId}
>
{sidebarLink.svgIcon}
{intl.formatMessage(
menuMessages[sidebarLink.messagesKey]
)}
</a>
</Link> </Link>
); );
})} })}

View File

@@ -1,14 +1,18 @@
import Infinity from '@app/assets/infinity.svg'; import Infinity from '@app/assets/infinity.svg';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import ProgressCircle from '@app/components/Common/ProgressCircle'; import ProgressCircle from '@app/components/Common/ProgressCircle';
import defineMessages from '@app/utils/defineMessages';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages(
movierequests: 'Movie Requests', 'components.Layout.UserDropdown.MiniQuotaDisplay',
seriesrequests: 'Series Requests', {
}); movierequests: 'Movie Requests',
seriesrequests: 'Series Requests',
}
);
type MiniQuotaDisplayProps = { type MiniQuotaDisplayProps = {
userId: number; userId: number;

View File

@@ -1,5 +1,6 @@
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay'; import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react'; import { Menu, Transition } from '@headlessui/react';
import { import {
ArrowRightOnRectangleIcon, ArrowRightOnRectangleIcon,
@@ -7,12 +8,13 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid'; import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import axios from 'axios'; import axios from 'axios';
import Image from 'next/image';
import type { LinkProps } from 'next/link'; import type { LinkProps } from 'next/link';
import Link from 'next/link'; import Link from 'next/link';
import { forwardRef, Fragment } from 'react'; import { forwardRef, Fragment } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Layout.UserDropdown', {
myprofile: 'Profile', myprofile: 'Profile',
settings: 'Settings', settings: 'Settings',
requests: 'Requests', requests: 'Requests',
@@ -24,10 +26,8 @@ const ForwardedLink = forwardRef<
LinkProps & React.ComponentPropsWithoutRef<'a'> LinkProps & React.ComponentPropsWithoutRef<'a'>
>(({ href, children, ...rest }, ref) => { >(({ href, children, ...rest }, ref) => {
return ( return (
<Link href={href}> <Link href={href} ref={ref} {...rest}>
<a ref={ref} {...rest}> {children}
{children}
</a>
</Link> </Link>
); );
}); });
@@ -53,10 +53,12 @@ const UserDropdown = () => {
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500" className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
data-testid="user-menu" data-testid="user-menu"
> >
<img <Image
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar} src={user?.avatar || ''}
alt="" alt=""
width={40}
height={40}
/> />
</Menu.Button> </Menu.Button>
</div> </div>
@@ -74,10 +76,12 @@ const UserDropdown = () => {
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur"> <div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
<div className="flex flex-col space-y-4 px-4 py-4"> <div className="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<img <Image
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar} src={user?.avatar || ''}
alt="" alt=""
width={40}
height={40}
/> />
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
<span className="truncate text-xl font-semibold text-gray-200"> <span className="truncate text-xl font-semibold text-gray-200">

View File

@@ -1,10 +1,10 @@
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import Link from 'next/link'; import Link from 'next/link';
import type React from 'react'; import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages('components.Layout.UserWarnings', {
emailRequired: 'An email address is required.', emailRequired: 'An email address is required.',
emailInvalid: 'Email address is invalid.', emailInvalid: 'Email address is invalid.',
passwordRequired: 'A password is required.', passwordRequired: 'A password is required.',
@@ -37,24 +37,23 @@ const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
} }
res = ( res = (
<Link href={link}> <Link
<a href={link}
onClick={onClick} onClick={onClick}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && onClick) { if (e.key === 'Enter' && onClick) {
onClick(); onClick();
} }
}} }}
role="button" role="button"
tabIndex={0} tabIndex={0}
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400" className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
> >
<ExclamationTriangleIcon className="h-6 w-6" /> <ExclamationTriangleIcon className="h-6 w-6" />
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0"> <div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
<span className="font-bold">{warningTitle}</span> <span className="font-bold">{warningTitle}</span>
<span className="truncate">{warningText}</span> <span className="truncate">{warningText}</span>
</div> </div>
</a>
</Link> </Link>
); );
}); });

View File

@@ -1,3 +1,4 @@
import defineMessages from '@app/utils/defineMessages';
import { import {
ArrowUpCircleIcon, ArrowUpCircleIcon,
BeakerIcon, BeakerIcon,
@@ -6,10 +7,10 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces'; import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
import Link from 'next/link'; import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages('components.Layout.VersionStatus', {
streamdevelop: 'Jellyseerr Develop', streamdevelop: 'Jellyseerr Develop',
streamstable: 'Jellyseerr Stable', streamstable: 'Jellyseerr Stable',
outofdate: 'Out of Date', outofdate: 'Out of Date',
@@ -39,49 +40,48 @@ const VersionStatus = ({ onClick }: VersionStatusProps) => {
: intl.formatMessage(messages.streamstable); : intl.formatMessage(messages.streamstable);
return ( return (
<Link href="/settings/about"> <Link
<a href="/settings/about"
onClick={onClick} onClick={onClick}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && onClick) { if (e.key === 'Enter' && onClick) {
onClick(); onClick();
} }
}} }}
role="button" role="button"
tabIndex={0} tabIndex={0}
className={`mx-2 flex items-center rounded-lg p-2 text-xs ring-1 ring-gray-700 transition duration-300 ${ className={`mx-2 flex items-center rounded-lg p-2 text-xs ring-1 ring-gray-700 transition duration-300 ${
data.updateAvailable data.updateAvailable
? 'bg-yellow-500 text-white hover:bg-yellow-400' ? 'bg-yellow-500 text-white hover:bg-yellow-400'
: 'bg-gray-900 text-gray-300 hover:bg-gray-800' : 'bg-gray-900 text-gray-300 hover:bg-gray-800'
}`} }`}
> >
{data.commitTag === 'local' ? ( {data.commitTag === 'local' ? (
<CodeBracketIcon className="h-6 w-6" /> <CodeBracketIcon className="h-6 w-6" />
) : data.version.startsWith('develop-') ? ( ) : data.version.startsWith('develop-') ? (
<BeakerIcon className="h-6 w-6" /> <BeakerIcon className="h-6 w-6" />
) : ( ) : (
<ServerIcon className="h-6 w-6" /> <ServerIcon className="h-6 w-6" />
)} )}
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0"> <div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
<span className="font-bold">{versionStream}</span> <span className="font-bold">{versionStream}</span>
<span className="truncate"> <span className="truncate">
{data.commitTag === 'local' ? ( {data.commitTag === 'local' ? (
'(⌐■_■)' '(⌐■_■)'
) : data.commitsBehind > 0 ? ( ) : data.commitsBehind > 0 ? (
intl.formatMessage(messages.commitsbehind, { intl.formatMessage(messages.commitsbehind, {
commitsBehind: data.commitsBehind, commitsBehind: data.commitsBehind,
}) })
) : data.commitsBehind === -1 ? ( ) : data.commitsBehind === -1 ? (
intl.formatMessage(messages.outofdate) intl.formatMessage(messages.outofdate)
) : ( ) : (
<code className="bg-transparent p-0"> <code className="bg-transparent p-0">
{data.version.replace('develop-', '')} {data.version.replace('develop-', '')}
</code> </code>
)} )}
</span> </span>
</div> </div>
{data.updateAvailable && <ArrowUpCircleIcon className="h-6 w-6" />} {data.updateAvailable && <ArrowUpCircleIcon className="h-6 w-6" />}
</a>
</Link> </Link>
); );
}; };

View File

@@ -1,13 +1,13 @@
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import axios from 'axios'; import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import type React from 'react'; import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import * as Yup from 'yup'; import * as Yup from 'yup';
const messages = defineMessages({ const messages = defineMessages('components.Login', {
title: 'Add Email', title: 'Add Email',
description: description:
'Since this is your first time logging into {applicationName}, you are required to add a valid email address.', 'Since this is your first time logging into {applicationName}, you are required to add a valid email address.',

View File

@@ -1,20 +1,23 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { InformationCircleIcon } from '@heroicons/react/24/solid'; import { InformationCircleIcon } from '@heroicons/react/24/solid';
import { ApiErrorCode } from '@server/constants/error'; import { ApiErrorCode } from '@server/constants/error';
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config'; import getConfig from 'next/config';
import type React from 'react'; import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup'; import * as Yup from 'yup';
const messages = defineMessages({ const messages = defineMessages('components.Login', {
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
host: '{mediaServerName} URL', hostname: '{mediaServerName} URL',
port: 'Port',
enablessl: 'Use SSL',
urlBase: 'URL Base',
email: 'Email', email: 'Email',
emailtooltip: emailtooltip:
'Address does not need to be associated with your {mediaServerName} instance.', 'Address does not need to be associated with your {mediaServerName} instance.',
@@ -24,6 +27,11 @@ const messages = defineMessages({
validationemailformat: 'Valid email required', validationemailformat: 'Valid email required',
validationusernamerequired: 'Username required', validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password required', validationpasswordrequired: 'Password required',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
loginerror: 'Something went wrong while trying to sign in.', loginerror: 'Something went wrong while trying to sign in.',
adminerror: 'You must use an admin account to sign in.', adminerror: 'You must use an admin account to sign in.',
credentialerror: 'The username or password is incorrect.', credentialerror: 'The username or password is incorrect.',
@@ -51,16 +59,23 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
if (initial) { if (initial) {
const LoginSchema = Yup.object().shape({ const LoginSchema = Yup.object().shape({
host: Yup.string() hostname: Yup.string().required(
intl.formatMessage(messages.validationhostrequired, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})
),
port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired)
),
urlBase: Yup.string()
.matches( .matches(
/^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/, /^(\/[^/].*[^/]$)/,
intl.formatMessage(messages.validationhostformat) intl.formatMessage(messages.validationUrlBaseLeadingSlash)
) )
.required( .matches(
intl.formatMessage(messages.validationhostrequired, { /^(.*[^/])$/,
mediaServerName: intl.formatMessage(messages.validationUrlBaseTrailingSlash)
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})
), ),
email: Yup.string() email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat)) .email(intl.formatMessage(messages.validationemailformat))
@@ -75,12 +90,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
mediaServerName: mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
}; };
return ( return (
<Formik <Formik
initialValues={{ initialValues={{
username: '', username: '',
password: '', password: '',
host: '', hostname: '',
port: 8096,
useSsl: false,
urlBase: '',
email: '', email: '',
}} }}
validationSchema={LoginSchema} validationSchema={LoginSchema}
@@ -89,7 +108,10 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
await axios.post('/api/v1/auth/jellyfin', { await axios.post('/api/v1/auth/jellyfin', {
username: values.username, username: values.username,
password: values.password, password: values.password,
hostname: values.host, hostname: values.hostname,
port: values.port,
useSsl: values.useSsl,
urlBase: values.urlBase,
email: values.email, email: values.email,
}); });
} catch (e) { } catch (e) {
@@ -121,32 +143,100 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
} }
}} }}
> >
{({ errors, touched, isSubmitting, isValid }) => ( {({
errors,
touched,
values,
setFieldValue,
isSubmitting,
isValid,
}) => (
<Form> <Form>
<div className="sm:border-t sm:border-gray-800"> <div className="sm:border-t sm:border-gray-800">
<label htmlFor="host" className="text-label"> <div className="flex flex-col sm:flex-row sm:gap-4">
{intl.formatMessage(messages.host, mediaServerFormatValues)} <div className="w-full">
<label htmlFor="hostname" className="text-label">
{intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
{values.useSsl ? 'https://' : 'http://'}
</span>
<Field
id="hostname"
name="hostname"
type="text"
className="rounded-r-only flex-1"
placeholder={intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
/>
</div>
{errors.hostname && touched.hostname && (
<div className="error">{errors.hostname}</div>
)}
</div>
</div>
<div className="flex-1">
<label htmlFor="port" className="text-label">
{intl.formatMessage(messages.port)}
</label>
<div className="mt-1 sm:mt-0">
<Field
id="port"
name="port"
inputMode="numeric"
type="text"
className="short flex-1"
placeholder={intl.formatMessage(messages.port)}
/>
{errors.port && touched.port && (
<div className="error">{errors.port}</div>
)}
</div>
</div>
</div>
<label htmlFor="useSsl" className="text-label mt-2">
{intl.formatMessage(messages.enablessl)}
</label>
<div className="mt-1 mb-2 sm:col-span-2">
<div className="flex rounded-md shadow-sm">
<Field
id="useSsl"
name="useSsl"
type="checkbox"
onChange={() => {
setFieldValue('useSsl', !values.useSsl);
setFieldValue('port', values.useSsl ? 8096 : 443);
}}
/>
</div>
</div>
<label htmlFor="urlBase" className="text-label mt-1">
{intl.formatMessage(messages.urlBase)}
</label> </label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0"> <div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm"> <div className="flex rounded-md shadow-sm">
<Field <Field
id="host"
name="host"
type="text" type="text"
placeholder={intl.formatMessage( inputMode="url"
messages.host, id="urlBase"
mediaServerFormatValues name="urlBase"
)} placeholder={intl.formatMessage(messages.urlBase)}
/> />
</div> </div>
{errors.host && touched.host && ( {errors.urlBase && touched.urlBase && (
<div className="error">{errors.host}</div> <div className="error">{errors.urlBase}</div>
)} )}
</div> </div>
<label <label
htmlFor="email" htmlFor="email"
className="text-label" className="text-label inline-flex gap-1 align-middle"
style={{ display: 'inline-flex' }}
> >
{intl.formatMessage(messages.email)} {intl.formatMessage(messages.email)}
<span className="label-tip"> <span className="label-tip">
@@ -162,7 +252,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
</Tooltip> </Tooltip>
</span> </span>
</label> </label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0"> <div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
<div className="flex rounded-md shadow-sm"> <div className="flex rounded-md shadow-sm">
<Field <Field
id="email" id="email"

View File

@@ -1,6 +1,7 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import SensitiveInput from '@app/components/Common/SensitiveInput'; import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { import {
ArrowLeftOnRectangleIcon, ArrowLeftOnRectangleIcon,
LifebuoyIcon, LifebuoyIcon,
@@ -9,10 +10,10 @@ import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import * as Yup from 'yup'; import * as Yup from 'yup';
const messages = defineMessages({ const messages = defineMessages('components.Login', {
username: 'Username', username: 'Username',
email: 'Email Address', email: 'Email Address',
password: 'Password', password: 'Password',
@@ -137,7 +138,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
</span> </span>
{passwordResetEnabled && ( {passwordResetEnabled && (
<span className="inline-flex rounded-md shadow-sm"> <span className="inline-flex rounded-md shadow-sm">
<Link href="/resetpassword" passHref> <Link href="/resetpassword" passHref legacyBehavior>
<Button as="a" buttonType="ghost"> <Button as="a" buttonType="ghost">
<LifebuoyIcon /> <LifebuoyIcon />
<span> <span>

View File

@@ -6,18 +6,20 @@ import LocalLogin from '@app/components/Login/LocalLogin';
import PlexLoginButton from '@app/components/PlexLoginButton'; import PlexLoginButton from '@app/components/PlexLoginButton';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid'; import { XCircleIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import axios from 'axios'; import axios from 'axios';
import getConfig from 'next/config'; import getConfig from 'next/config';
import { useRouter } from 'next/dist/client/router'; import { useRouter } from 'next/dist/client/router';
import Image from 'next/image';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import JellyfinLogin from './JellyfinLogin'; import JellyfinLogin from './JellyfinLogin';
const messages = defineMessages({ const messages = defineMessages('components.Login', {
signin: 'Sign In', signin: 'Sign In',
signinheader: 'Sign in to continue', signinheader: 'Sign in to continue',
signinwithplex: 'Use your Plex account', signinwithplex: 'Use your Plex account',
@@ -86,8 +88,10 @@ const Login = () => {
<LanguagePicker /> <LanguagePicker />
</div> </div>
<div className="relative z-40 mt-10 flex flex-col items-center px-4 sm:mx-auto sm:w-full sm:max-w-md"> <div className="relative z-40 mt-10 flex flex-col items-center px-4 sm:mx-auto sm:w-full sm:max-w-md">
<img src="/logo_stacked.svg" className="mb-10 max-w-full" alt="Logo" /> <div className="relative h-48 w-full max-w-full">
<h2 className="mt-2 text-center text-3xl font-extrabold leading-9 text-gray-100"> <Image src="/logo_stacked.svg" alt="Logo" fill />
</div>
<h2 className="mt-12 text-center text-3xl font-extrabold leading-9 text-gray-100">
{intl.formatMessage(messages.signinheader)} {intl.formatMessage(messages.signinheader)}
</h2> </h2>
</div> </div>

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