Files
jellyseerr/server/routes/user/index.ts
Gauthier 1635932375 chore: merge upstream (#1466)
* 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)

* fix: correct deeplinks on iPad (#3883)

* feat(studios): add a24 to studios list (#3902)

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* feat(watchlist): Cache watchlist requests with matching E-Tags (#3901)

* perf(watchlist): add E-Tag caching to Plex watchlist requests

* refactor(watchlist): increase frequency of watchlist requests

* fix: sync watchlist every 3 min instead of 3 sec

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* feat(plex): refresh token schedule (#3875)

* feat: refresh token schedule

fix #3861

* fix(i18n): add i18n message

* refactor(plextv): use randomUUID crypto instead custom function

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* fix: correct icon showing on certain phones when not pulled (#3939)

* feat: add support for requesting "Specials" for TV Shows (#3724)

* feat: add support for requesting "Specials" for TV Shows

This commit is responsible for adding support in Overseerr for requesting "Special" episodes for TV
Shows. This request has become especially pertinent when you consider shows like "Doctor Who". These
shows have Specials that are critical to understanding the plot of a TV show.

fix #779

* chore(yarn.lock): undo inappropriate changes to yarn.lock

I was informed by @sct in a comment on the #3724 PR that it was not appropriate to commit the
changes that ended up being made to the yarn.lock file. This commit is responsible, then, for
undoing the changes to the yarn.lock file that ended up being submitted.

* refactor: change loose equality to strict equality

I received a comment from OwsleyJr pointing out that we are using loose equality when we could
alternatively just be using strict equality to increase the robustness of our code. This commit
does exactly that by squashing out previous usages of loose equality in my commits and replacing
them with strict equality

* refactor: move 'Specials' string to a global message

Owsley pointed out that we are redefining the 'Specials' string multiple times throughout this PR.
Instead, we can just move it as a global message. This commit does exactly that. It squashes out and
previous declarations of the 'Specials' string inside the src files, and moves it directly to the
global messages file.

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* feat(lang): Translations update from Hosted Weblate (#3835)

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 100.0% (1240 of 1240 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1241 of 1241 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/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ca/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Hungarian)

Currently translated at 99.2% (1231 of 1240 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Polish)

Currently translated at 98.8% (1227 of 1241 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1241 of 1241 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robin Van de Vyvere <irazoxgames@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1241 of 1241 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1241 of 1241 strings)

Co-authored-by: Frostar <dasangra@hotmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
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)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1241 of 1241 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1240 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Nackophilz <zrv4flra@anonaddy.me>
Co-authored-by: TayZ3r <artimmo@hotmail.fr>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Swedish)

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Per Erik <urbanlolface@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
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.9% (36 of 1240 strings)

Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fi/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Albanian)

Currently translated at 95.8% (1189 of 1240 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Korean)

Currently translated at 100.0% (1241 of 1241 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 100.0% (1240 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hyun Lee <hyun@yahoo.com>
Co-authored-by: cutiekeek <cutiekeek@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ko/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 98.4% (1221 of 1240 strings)

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

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

Currently translated at 99.9% (1239 of 1240 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 98.2% (1219 of 1241 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marc Lerno <mlerno1192@student.carlalbert.edu>
Co-authored-by: dtalens <databio@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 89.9% (1115 of 1240 strings)

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

---------

Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com>
Co-authored-by: Michael Michael <michaelvelosk@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Dargo <fuszi88@gmail.com>
Co-authored-by: senza <senza@users.noreply.hosted.weblate.org>
Co-authored-by: Robin Van de Vyvere <irazoxgames@gmail.com>
Co-authored-by: Frostar <dasangra@hotmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Nackophilz <zrv4flra@anonaddy.me>
Co-authored-by: TayZ3r <artimmo@hotmail.fr>
Co-authored-by: Per Erik <urbanlolface@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: W L <wl@mailhole.de>
Co-authored-by: Hyun Lee <hyun@yahoo.com>
Co-authored-by: cutiekeek <cutiekeek@gmail.com>
Co-authored-by: Rafael Souto <git@rafaelsouto.com>
Co-authored-by: Marc Lerno <mlerno1192@student.carlalbert.edu>
Co-authored-by: exentler <gurandsrud@gmail.com>

* feat(ui): prevent password manager interference & improve service links (#3989)

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* fix(ui): update Plex Logo (#3955)

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* feat: requests/issues menu count (#3470)

* feat: request and issue count added to sidebar/mobile menu

* fix: added permission check for count visibility

* refactor: modified badge design for count

* fix: properly update issue and request counts in certain scenarios (#4051)

* fix: center count badge on sidebar and mobile menu (#4052)

* fix: request english trailers as a fallback when using other languages (#4009)

Co-authored-by: Stancu Florin <florin@stancu.me>

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* feat: added the PWA badge indicator for requests pending (#3411)

refactor: removed unnecessary code when sending web push notification

fix: moved all notify user logic into webpush

refactor: n

refactor: remove all unnecessary prettier changes

fix: n

fix: n

fix: n

fix: n

fix: increment sw version

fix: n

* fix: improve count badge styling (#4056)

* fix: improved web push management (#3421)

refactor: organized placement of new button + added comments

fix: added api routes for push registration

fix: modified get request to confirm key identity

fix: added back notification types to always show

feat: added a manageable device list

refactor: modified device list to make it mobile friendly

fix: correct typo for enabling notifications

* Revert "fix: improved web push management (#3421)" (#4058)

* fix: manage webpush notifications (#4059)

* feat(lang): Translations update from Hosted Weblate (#4025)

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 100.0% (1240 of 1240 strings)

Co-authored-by: Felipe Garcia <garcia.o.felipe@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1240 of 1240 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 40.8% (507 of 1240 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Hungarian)

Currently translated at 99.9% (1239 of 1240 strings)

feat(lang): translated using Weblate (Hungarian)

Currently translated at 99.7% (1237 of 1240 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Italian)

Currently translated at 95.3% (1182 of 1240 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 95.3% (1182 of 1240 strings)

Co-authored-by: Alberto Giardino <alberto.giardino@al-ce.it>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* feat(lang): added translation using Weblate (Slovenian)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: sct <sctsnipe@gmail.com>

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

---------

Co-authored-by: Felipe Garcia <garcia.o.felipe@gmail.com>
Co-authored-by: Rico <rico.jambor@gmail.com>
Co-authored-by: George L <lazugeorgem@gmail.com>
Co-authored-by: Yaroslav Buzko <yaroslav@buzko.com>
Co-authored-by: ugyes <ferenc.bodi@live.com>
Co-authored-by: Alberto Giardino <alberto.giardino@al-ce.it>
Co-authored-by: sct <sctsnipe@gmail.com>

* fix: change localhost to process.env.HOST for client requests (#3839)

* Change localhost to process.env.HOST for client requests

* refactor: reformat

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* fix: set the correct TTL for the cookie store (#3946)

The time-to-live (TTL) of cookies stored in the database was incorrect because the connect-typeorm
library takes a TTL in seconds and not milliseconds, making cookies valid for ~82 years instead of
30 days.

Co-authored-by: Ryan Cohen <ryan@sct.dev>

* docs: add gauthier-th as a contributor for code (#4064) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* fix: update migration script (#4065)

* fix: update migration script

fix: remove insert for new entities

* fix: correct migration name

* fix: correct name inside migration

* fix(servarr): merge series tags instead of overwriting them (#4019)

* Merge series tags instead of overwriting when adding a series that already exists

Currently, a request coming in for a series that already exists in sonarr nukes the tags in sonarr for the series in favor of the tags coming from overseerr. This change merges the two lists of tags and deduplicates them before sending them to sonarr.

* fix(servarr api): merge request media tags with servarr instead of overwriting

---------

Co-authored-by: Danshil Kokil Mungur <me@danshilm.com>

---------

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: Fallenbagel <98979876+Fallenbagel@users.noreply.github.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>
Co-authored-by: Brandon Cohen <brandon@z3hn.dev>
Co-authored-by: David Emrich <demrich@me.com>
Co-authored-by: Max T. Kristiansen <me@maxtrier.dk>
Co-authored-by: Damien Fajole <60252259+DamsDev1@users.noreply.github.com>
Co-authored-by: Ahmed Siddiqui <36286128+AhmedNSidd@users.noreply.github.com>
Co-authored-by: Dargo <fuszi88@gmail.com>
Co-authored-by: senza <senza@users.noreply.hosted.weblate.org>
Co-authored-by: Robin Van de Vyvere <irazoxgames@gmail.com>
Co-authored-by: Frostar <dasangra@hotmail.com>
Co-authored-by: Nackophilz <zrv4flra@anonaddy.me>
Co-authored-by: TayZ3r <artimmo@hotmail.fr>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: W L <wl@mailhole.de>
Co-authored-by: Hyun Lee <hyun@yahoo.com>
Co-authored-by: cutiekeek <cutiekeek@gmail.com>
Co-authored-by: Rafael Souto <git@rafaelsouto.com>
Co-authored-by: Marc Lerno <mlerno1192@student.carlalbert.edu>
Co-authored-by: exentler <gurandsrud@gmail.com>
Co-authored-by: soup <s0up4200@pm.me>
Co-authored-by: JackOXI <53652452+JackW6809@users.noreply.github.com>
Co-authored-by: Stancu Florin <StancuFlorin@users.noreply.github.com>
Co-authored-by: Stancu Florin <florin@stancu.me>
Co-authored-by: Brandon Cohen <cohbrandon@gmail.com>
Co-authored-by: Felipe Garcia <garcia.o.felipe@gmail.com>
Co-authored-by: Rico <rico.jambor@gmail.com>
Co-authored-by: George L <lazugeorgem@gmail.com>
Co-authored-by: Yaroslav Buzko <yaroslav@buzko.com>
Co-authored-by: ugyes <ferenc.bodi@live.com>
Co-authored-by: Alberto Giardino <alberto.giardino@al-ce.it>
Co-authored-by: Lukas Miklosko <44380311+lmiklosko@users.noreply.github.com>
Co-authored-by: Ryan Cohen <ryan@sct.dev>
Co-authored-by: Andrew Kennedy <andrew-kennedy@users.noreply.github.com>
Co-authored-by: Danshil Kokil Mungur <me@danshilm.com>
2025-03-24 23:45:33 +08:00

876 lines
25 KiB
TypeScript

import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
import { Watchlist } from '@server/entity/Watchlist';
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
import type {
QuotaResponse,
UserRequestsResponse,
UserResultsResponse,
UserWatchDataResponse,
} from '@server/interfaces/api/userInterfaces';
import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash';
import { In } from 'typeorm';
import userSettingsRoutes from './usersettings';
const router = Router();
router.get('/', async (req, res, next) => {
try {
const includeIds = [
...new Set(
req.query.includeIds ? req.query.includeIds.toString().split(',') : []
),
];
const pageSize = req.query.take
? Number(req.query.take)
: Math.max(10, includeIds.length);
const skip = req.query.skip ? Number(req.query.skip) : 0;
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
let query = getRepository(User).createQueryBuilder('user');
if (q) {
query = query.where(
'LOWER(user.username) LIKE :q OR LOWER(user.email) LIKE :q OR LOWER(user.plexUsername) LIKE :q OR LOWER(user.jellyfinUsername) LIKE :q',
{ q: `%${q}%` }
);
}
if (includeIds.length > 0) {
query.andWhereInIds(includeIds);
}
switch (req.query.sort) {
case 'updated':
query = query.orderBy('user.updatedAt', 'DESC');
break;
case 'displayname':
query = query
.addSelect(
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
"user"."email"
ELSE
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.username)
END`,
'displayname_sort_key'
)
.orderBy('displayname_sort_key', 'ASC');
break;
case 'requests':
query = query
.addSelect((subQuery) => {
return subQuery
.select('COUNT(request.id)', 'request_count')
.from(MediaRequest, 'request')
.where('request.requestedBy.id = user.id');
}, 'request_count')
.orderBy('request_count', 'DESC');
break;
default:
query = query.orderBy('user.id', 'ASC');
break;
}
const [users, userCount] = await query
.take(pageSize)
.skip(skip)
.distinct(true)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(userCount / pageSize),
pageSize,
results: userCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: User.filterMany(
users,
req.user?.hasPermission(Permission.MANAGE_USERS)
),
} as UserResultsResponse);
} catch (e) {
next({ status: 500, message: e.message });
}
});
router.post(
'/',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
try {
const settings = getSettings();
const body = req.body;
const email = body.email || body.username;
const userRepository = getRepository(User);
const existingUser = await userRepository
.createQueryBuilder('user')
.where('user.email = :email', {
email: email.toLowerCase(),
})
.getOne();
if (existingUser) {
return next({
status: 409,
message: 'User already exists with submitted email.',
errors: ['USER_EXISTS'],
});
}
const passedExplicitPassword = body.password && body.password.length > 0;
const avatar = gravatarUrl(email, { default: 'mm', size: 200 });
if (
!passedExplicitPassword &&
!settings.notifications.agents.email.enabled
) {
throw new Error('Email notifications must be enabled');
}
const user = new User({
email,
avatar: body.avatar ?? avatar,
username: body.username,
password: body.password,
permissions: settings.main.defaultPermissions,
plexToken: '',
userType: UserType.LOCAL,
});
if (passedExplicitPassword) {
await user?.setPassword(body.password);
} else {
await user?.generatePassword();
}
await userRepository.save(user);
return res.status(201).json(user.filter());
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
router.post<
never,
unknown,
{
endpoint: string;
p256dh: string;
auth: string;
userAgent: string;
}
>('/registerPushSubscription', async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
const existingSubs = await userPushSubRepository.find({
relations: { user: true },
where: { auth: req.body.auth, user: { id: req.user?.id } },
});
if (existingSubs.length > 0) {
logger.debug(
'User push subscription already exists. Skipping registration.',
{ label: 'API' }
);
return res.status(204).send();
}
const userPushSubscription = new UserPushSubscription({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
userPushSubRepository.save(userPushSubscription);
return res.status(204).send();
} catch (e) {
logger.error('Failed to register user push subscription', {
label: 'API',
});
next({ status: 500, message: 'Failed to register subscription.' });
}
});
router.get<{ userId: number }>(
'/:userId/pushSubscriptions',
async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
const userPushSubs = await userPushSubRepository.find({
relations: { user: true },
where: { user: { id: req.params.userId } },
});
return res.status(200).json(userPushSubs);
} catch (e) {
next({ status: 404, message: 'User subscriptions not found.' });
}
}
);
router.get<{ userId: number; key: string }>(
'/:userId/pushSubscription/:key',
async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
const userPushSub = await userPushSubRepository.findOneOrFail({
relations: {
user: true,
},
where: {
user: { id: req.params.userId },
p256dh: req.params.key,
},
});
return res.status(200).json(userPushSub);
} catch (e) {
next({ status: 404, message: 'User subscription not found.' });
}
}
);
router.delete<{ userId: number; key: string }>(
'/:userId/pushSubscription/:key',
async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
const userPushSub = await userPushSubRepository.findOneOrFail({
relations: {
user: true,
},
where: {
user: { id: req.params.userId },
p256dh: req.params.key,
},
});
await userPushSubRepository.remove(userPushSub);
return res.status(204).send();
} catch (e) {
logger.error('Something went wrong deleting the user push subcription', {
label: 'API',
key: req.params.key,
errorMessage: e.message,
});
return next({
status: 500,
message: 'User push subcription not found',
});
}
}
);
router.get<{ id: string }>('/:id', async (req, res, next) => {
try {
const userRepository = getRepository(User);
const user = await userRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
return res
.status(200)
.json(user.filter(req.user?.hasPermission(Permission.MANAGE_USERS)));
} catch (e) {
next({ status: 404, message: 'User not found.' });
}
});
router.use('/:id/settings', userSettingsRoutes);
router.get<{ id: string }, UserRequestsResponse>(
'/:id/requests',
async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 20;
const skip = req.query.skip ? Number(req.query.skip) : 0;
try {
const user = await getRepository(User).findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
if (
user.id !== req.user?.id &&
!req.user?.hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
)
) {
return next({
status: 403,
message: "You do not have permission to view this user's requests.",
});
}
const [requests, requestCount] = await getRepository(MediaRequest)
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.leftJoinAndSelect('request.seasons', 'seasons')
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
.andWhere('requestedBy.id = :id', {
id: user.id,
})
.orderBy('request.id', 'DESC')
.take(pageSize)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(requestCount / pageSize),
pageSize,
results: requestCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: requests,
});
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
export const canMakePermissionsChange = (
permissions: number,
user?: User
): boolean =>
// Only let the owner grant admin privileges
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1);
router.put<
Record<string, never>,
Partial<User>[],
{ ids: string[]; permissions: number }
>('/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => {
try {
const isOwner = req.user?.id === 1;
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
return next({
status: 403,
message: 'You do not have permission to grant this level of access',
});
}
const userRepository = getRepository(User);
const users: User[] = await userRepository.find({
where: {
id: In(
isOwner ? req.body.ids : req.body.ids.filter((id) => Number(id) !== 1)
),
},
});
const updatedUsers = await Promise.all(
users.map(async (user) => {
return userRepository.save(<User>{
...user,
...{ permissions: req.body.permissions },
});
})
);
return res.status(200).json(updatedUsers);
} catch (e) {
next({ status: 500, message: e.message });
}
});
router.put<{ id: string }>(
'/:id',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
try {
const userRepository = getRepository(User);
const user = await userRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
// Only let the owner user modify themselves
if (user.id === 1 && req.user?.id !== 1) {
return next({
status: 403,
message: 'You do not have permission to modify this user',
});
}
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
return next({
status: 403,
message: 'You do not have permission to grant this level of access',
});
}
Object.assign(user, {
username: req.body.username,
permissions: req.body.permissions,
});
await userRepository.save(user);
return res.status(200).json(user.filter());
} catch (e) {
next({ status: 404, message: 'User not found.' });
}
}
);
router.delete<{ id: string }>(
'/:id',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
try {
const userRepository = getRepository(User);
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
relations: { requests: true },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
if (user.id === 1) {
return next({
status: 405,
message: 'This account cannot be deleted.',
});
}
if (user.hasPermission(Permission.ADMIN) && req.user?.id !== 1) {
return next({
status: 405,
message: 'You cannot delete users with administrative privileges.',
});
}
const requestRepository = getRepository(MediaRequest);
/**
* Requests are usually deleted through a cascade constraint. Those however, do
* not trigger the removal event so listeners to not run and the parent Media
* will not be updated back to unknown for titles that were still pending. So
* we manually remove all requests from the user here so the parent media's
* properly reflect the change.
*/
await requestRepository.remove(user.requests, {
/**
* Break-up into groups of 1000 requests to be removed at a time.
* Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs.
* https://typeorm.io/repository-api#additional-options
*/
chunk: user.requests.length / 1000,
});
await userRepository.delete(user.id);
return res.status(200).json(user.filter());
} catch (e) {
logger.error('Something went wrong deleting a user', {
label: 'API',
userId: req.params.id,
errorMessage: e.message,
});
return next({
status: 500,
message: 'Something went wrong deleting the user',
});
}
}
);
router.post(
'/import-from-plex',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
try {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as { plexIds: string[] } | undefined;
// taken from auth.ts
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
const plexUsersResponse = await mainPlexTv.getUsers();
const createdUsers: User[] = [];
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
if (account.email) {
const user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.getOne();
if (user) {
// Update the user's avatar with their Plex thumbnail, in case it changed
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
}
await userRepository.save(user);
} else if (!body || body.plexIds.includes(account.id)) {
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
const newUser = new User({
plexUsername: account.username,
email: account.email,
permissions: settings.main.defaultPermissions,
plexId: parseInt(account.id),
plexToken: '',
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
}
}
}
return res.status(201).json(User.filterMany(createdUsers));
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
router.post(
'/import-from-jellyfin',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
try {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as { jellyfinUserIds: string[] };
// taken from auth.ts
const admin = await userRepository.findOneOrFail({
where: { id: 1 },
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
order: { id: 'ASC' },
});
const hostname = getHostname();
const jellyfinClient = new JellyfinAPI(
hostname,
settings.jellyfin.apiKey,
admin.jellyfinDeviceId ?? ''
);
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = [];
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers();
for (const jellyfinUserId of body.jellyfinUserIds) {
const jellyfinUser = jellyfinUsers.users.find(
(user) => user.Id === jellyfinUserId
);
const user = await userRepository.findOne({
select: ['id', 'jellyfinUserId'],
where: { jellyfinUserId: jellyfinUserId },
});
if (!user) {
const newUser = new User({
jellyfinUsername: jellyfinUser?.Name,
jellyfinUserId: jellyfinUser?.Id,
jellyfinDeviceId: Buffer.from(
`BOT_jellyseerr_${jellyfinUser?.Name ?? ''}`
).toString('base64'),
email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions,
avatar: `/avatarproxy/${jellyfinUser?.Id}`,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
}
return res.status(201).json(User.filterMany(createdUsers));
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
router.get<{ id: string }, QuotaResponse>(
'/:id/quota',
async (req, res, next) => {
try {
const userRepository = getRepository(User);
if (
Number(req.params.id) !== req.user?.id &&
!req.user?.hasPermission(
[Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS],
{ type: 'and' }
)
) {
return next({
status: 403,
message:
"You do not have permission to view this user's request limits.",
});
}
const user = await userRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
const quotas = await user.getQuota();
return res.status(200).json(quotas);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
router.get<{ id: string }, UserWatchDataResponse>(
'/:id/watch_data',
async (req, res, next) => {
if (
Number(req.params.id) !== req.user?.id &&
!req.user?.hasPermission(Permission.ADMIN)
) {
return next({
status: 403,
message:
"You do not have permission to view this user's recently watched media.",
});
}
const settings = getSettings().tautulli;
if (!settings.hostname || !settings.port || !settings.apiKey) {
return next({
status: 404,
message: 'Tautulli API not configured.',
});
}
try {
const user = await getRepository(User).findOneOrFail({
where: { id: Number(req.params.id) },
select: { id: true, plexId: true },
});
const tautulli = new TautulliAPI(settings);
const watchStats = await tautulli.getUserWatchStats(user);
const watchHistory = await tautulli.getUserWatchHistory(user);
const recentlyWatched = sortBy(
await getRepository(Media).find({
where: [
{
mediaType: MediaType.MOVIE,
ratingKey: In(
watchHistory
.filter((record) => record.media_type === 'movie')
.map((record) => record.rating_key)
),
},
{
mediaType: MediaType.MOVIE,
ratingKey4k: In(
watchHistory
.filter((record) => record.media_type === 'movie')
.map((record) => record.rating_key)
),
},
{
mediaType: MediaType.TV,
ratingKey: In(
watchHistory
.filter((record) => record.media_type === 'episode')
.map((record) => record.grandparent_rating_key)
),
},
{
mediaType: MediaType.TV,
ratingKey4k: In(
watchHistory
.filter((record) => record.media_type === 'episode')
.map((record) => record.grandparent_rating_key)
),
},
],
}),
[
(media) =>
findIndex(
watchHistory,
(record) =>
(!!media.ratingKey &&
parseInt(media.ratingKey) ===
(record.media_type === 'movie'
? record.rating_key
: record.grandparent_rating_key)) ||
(!!media.ratingKey4k &&
parseInt(media.ratingKey4k) ===
(record.media_type === 'movie'
? record.rating_key
: record.grandparent_rating_key))
),
]
);
return res.status(200).json({
recentlyWatched,
playCount: watchStats.total_plays,
});
} catch (e) {
logger.error('Something went wrong fetching user watch data', {
label: 'API',
errorMessage: e.message,
userId: req.params.id,
});
next({
status: 500,
message: 'Failed to fetch user watch data.',
});
}
}
);
router.get<{ id: string }, WatchlistResponse>(
'/:id/watchlist',
async (req, res, next) => {
if (
Number(req.params.id) !== req.user?.id &&
!req.user?.hasPermission(
[Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW],
{
type: 'or',
}
)
) {
return next({
status: 403,
message: "You do not have permission to view this user's Watchlist.",
});
}
const itemsPerPage = 20;
const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage;
const user = await getRepository(User).findOneOrFail({
where: { id: Number(req.params.id) },
select: ['id', 'plexToken'],
});
if (user) {
const [result, total] = await getRepository(Watchlist).findAndCount({
where: { requestedBy: { id: user?.id } },
relations: {
/*requestedBy: true,media:true*/
},
// loadRelationIds: true,
take: itemsPerPage,
skip: offset,
});
if (total) {
return res.json({
page: page,
totalPages: Math.ceil(total / itemsPerPage),
totalResults: total,
results: result,
});
}
}
// We will just return an empty array if the user has no Plex token
if (!user.plexToken) {
return res.json({
page: 1,
totalPages: 1,
totalResults: 0,
results: [],
});
}
const plexTV = new PlexTvAPI(user.plexToken);
const watchlist = await plexTV.getWatchlist({ offset });
return res.json({
page,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
id: item.tmdbId,
ratingKey: item.ratingKey,
title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie',
tmdbId: item.tmdbId,
})),
});
}
);
export default router;