Compare commits

...

125 Commits

Author SHA1 Message Date
semantic-release-bot
20c821e2eb chore(release): 1.4.0 2023-01-29 20:33:10 +00:00
Fallenbagel
7b82ced5e6 Merge pull request #312 from Fallenbagel/develop
Merge 'origin/develop' into main
2023-01-30 01:31:00 +05:00
Fallenbagel
4c22a71cdf Merge pull request #311 from Fallenbagel/upstream
Merge 'upstream/develop' into develop
2023-01-28 05:22:42 +05:00
Fallenbagel
b9e7933e09 docs(readme): revert to original README 2023-01-28 05:04:33 +05:00
Fallenbagel
2508b4340f refactor: updated the heroicons used in jellyfin settings/email warning 2023-01-28 04:44:46 +05:00
Fallenbagel
e1f67ad8ba Merge 'overseerr/develop' into develop 2023-01-28 03:40:12 +05:00
Fallenbagel
91abdb2ba5 Remove Zone.Identfier [skip ci] 2023-01-28 03:33:22 +05:00
notfakie
ad3d922440 Merge remote-tracking branch 'overseerr/develop' into develop 2023-01-27 17:55:55 +13:00
Danshil Kokil Mungur
51b05cd8fb fix(build): update usage of publish snap action (#3272)
* fix(build): use env variable to login with snapcraft 7

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

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

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

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

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

* docs(installation): fix typo

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

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.5% (1217 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.4% (1215 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.4% (1215 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.2% (1213 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 98.6% (1203 of 1219 strings)

feat(lang): translated using Weblate (French)

Currently translated at 92.6% (1130 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (French)

Currently translated at 96.0% (1132 of 1179 strings)

feat(lang): translated using Weblate (French)

Currently translated at 95.9% (1131 of 1179 strings)

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

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

Currently translated at 85.5% (1045 of 1222 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Polish)

Currently translated at 93.6% (1144 of 1222 strings)

feat(lang): translated using Weblate (Polish)

Currently translated at 90.6% (1105 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 100.0% (1219 of 1219 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 96.9% (1182 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1179 of 1179 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 94.0% (1105 of 1175 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 100.0% (1219 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Dutch)

Currently translated at 95.5% (1126 of 1179 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 93.8% (1107 of 1179 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 94.6% (1154 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1175 of 1175 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 94.7% (1113 of 1175 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 11.8% (145 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 9.4% (115 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 9.1% (112 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 9.1% (112 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 2.7% (33 of 1222 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Albanian)

Currently translated at 97.1% (1142 of 1175 strings)

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

Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Mathieu <math_du_88@yahoo.fr>
Co-authored-by: Sulli <susu.leduc@gmail.com>
Co-authored-by: Uruk <uruknarb20@gmail.com>
Co-authored-by: slundi <slundi@gmail.com>
Co-authored-by: Angel <adelpozoman@gmail.com>
Co-authored-by: Patryk <byakurau1@gmail.com>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Ben <ben.david.wallner@gmail.com>
Co-authored-by: exentler <gurandsrud@gmail.com>
Co-authored-by: Gaurang Goel <gaurang7goel+hosted@gmail.com>
Co-authored-by: Ishan Jindal <jindal25ishan@gmail.com>
Co-authored-by: Denis Çerri <deniscerri3@gmail.com>
2023-01-19 21:53:44 +09:00
Ryan Cohen
fcbca1722f feat: new mobile menu (#3251) 2023-01-17 18:00:15 +09:00
Ryan Cohen
19370f856c chore: add holopin config (#3250) [skip ci] 2023-01-17 10:36:00 +09:00
Ryan Cohen
154f3e72ef fix: correctly restore selected streaming service filters (#3249) 2023-01-16 21:34:47 +09:00
Ryan Cohen
6fd11cf425 fix: correct grid sizing for webkit on streaming services (#3248) 2023-01-16 21:04:55 +09:00
Ryan Cohen
1154156459 feat: add streaming services filter (#3247)
* feat: add streaming services filter

* fix: count watch region/provider as one filter
2023-01-16 17:05:21 +09:00
Danshil Kokil Mungur
cb650745f6 fix(request): mark request as approved if media is already available when retrying failed request (#3244) 2023-01-15 11:32:38 +04:00
Brandon Cohen
3aefddd488 fix: screen would zoom on mobile if date picker input was selected (#3241) 2023-01-14 11:40:39 +09:00
allcontributors[bot]
1bf0103422 docs: add aedelbro as a contributor for code (#3240) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-01-14 10:11:30 +09:00
aedelbro
a672b324ec feat(ui): add episode number to front of episode name in season details (#3086) 2023-01-14 01:10:38 +00:00
Brandon Cohen
a343f8ad91 fix: prevent double encode if we are on /search endpoint (#3238) 2023-01-13 23:21:54 +00:00
Fallenbagel
7a69cb35f8 Merge pull request #296 from CheChu10/patch-2
Update Spanish translations
2023-01-14 00:01:31 +05:00
Ryan Cohen
c2a1a20a3b fix: include new package calendar css in build (#3235) 2023-01-13 20:42:03 +09:00
Ryan Cohen
dd00e48f59 feat: discover overhaul (filters!) (#3232) 2023-01-13 16:54:35 +09:00
Danshil Kokil Mungur
b5157010c4 fix(request): approve request when retrying request (#3234)
chore(request): clarify comment
2023-01-12 16:34:53 +09:00
renovate[bot]
7b6db50ae5 fix(deps): update dependency swr to v2 (#3212)
* fix(deps): update dependency swr to v2

* fix: correct type import for swr

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: sct <ryan@sct.dev>
2023-01-09 16:28:15 +09:00
Ryan Cohen
3ba6df1a41 fix: correct checkbox position (again) for slider edits (#3227) 2023-01-09 15:15:26 +09:00
Ryan Cohen
2eebb7fd39 fix: restore border to ghost button and fix discover slider visibility toggle position (#3226) 2023-01-08 09:18:08 +09:00
Fallenbagel
f5c7a4fa97 Merge pull request #295 from undone37/patch-1
Updated German Translations
2023-01-08 00:34:27 +05:00
Chechu
50e85c975a Prettier fixed 2023-01-07 19:31:50 +01:00
Ryan Cohen
62e2de70bf fix: correct spacing between sliders (#3225) 2023-01-08 00:35:08 +09:00
Ryan Cohen
0683f4f000 refactor: redesign discover customization buttons (#3224) 2023-01-08 00:12:31 +09:00
Chechu García
b1bd569335 Update es.json
Added some missing Spanish translations and fixed a spelling mistake.
Ordered by JSON Keys.
2023-01-07 09:15:28 +00:00
undone37
a49ea92692 style(i18n german translation): fixed Prettier formatting issue of German translation file 2023-01-06 20:02:56 +01:00
Ryan Cohen
d23b2132de fix: improve small screen layout for discover editing (#3221) 2023-01-07 01:12:19 +09:00
Ryan Cohen
8bd10b5bf3 feat: discover inline customization (#3220) 2023-01-06 21:03:09 +09:00
undone37
08c9085f0d Updated German Translations
Added missing German translations and fixed some spelling mistakes.
Changed some German translations to match informal address of user.
Unification of wordings like "Episoden" and "Folgen".
2023-01-06 11:10:13 +01:00
Weblate (bot)
0d8b390b67 feat(lang): translations update from Hosted Weblate (#3030)
* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Spanish)

Currently translated at 84.8% (955 of 1125 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Joseph Valderrama Palacios <jvalderrama.es@gmail.com>
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 (Romanian)

Currently translated at 23.3% (263 of 1125 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 23.3% (263 of 1125 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 21.6% (243 of 1125 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 17.6% (199 of 1125 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 10.9% (123 of 1125 strings)

Co-authored-by: DragoPrime <emperordrago@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sergiu Pahontu <pahontusergiu@gmail.com>
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.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Polish)

Currently translated at 100.0% (1129 of 1129 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Russian)

Currently translated at 87.7% (987 of 1125 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 98.0% (1103 of 1125 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1125 of 1125 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 99.7% (1126 of 1129 strings)

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

Currently translated at 100.0% (1125 of 1125 strings)

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

Currently translated at 99.5% (1120 of 1125 strings)

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

Currently translated at 100.0% (1120 of 1120 strings)

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

Currently translated at 100.0% (1120 of 1120 strings)

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

Currently translated at 100.0% (1120 of 1120 strings)

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

Currently translated at 99.8% (1118 of 1120 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Hungarian)

Currently translated at 96.7% (1083 of 1119 strings)

feat(lang): translated using Weblate (Hungarian)

Currently translated at 94.3% (1056 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
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.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1129 of 1129 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.9% (1124 of 1125 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1120 of 1120 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Czech)

Currently translated at 100.0% (1119 of 1119 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 100.0% (1120 of 1120 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TheCatLady <o40yoym9@anonaddy.me>
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

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

Currently translated at 98.0% (1152 of 1175 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1129 of 1129 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Squizzy911 <tobias.mueller1210@gmail.com>
Co-authored-by: inkarnation <94744834+inkarnation@users.noreply.github.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.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 100.0% (1120 of 1120 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 95.0% (1069 of 1125 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 94.0% (1058 of 1125 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 93.8% (1050 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Micke Nilsson <mikni@proton.me>
Co-authored-by: Mikael Nilsson <mikni@proton.me>
Co-authored-by: Shjosan <shjosan@kakmix.co>
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 (Hebrew)

Currently translated at 12.1% (137 of 1125 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Izik Avinoam <izik.avi@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/he/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1120 of 1120 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.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Croatian)

Currently translated at 100.0% (1129 of 1129 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.9% (1124 of 1125 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 100.0% (1120 of 1120 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 100.0% (1119 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 98.3% (1100 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 96.9% (1085 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 96.0% (1075 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.3% (1067 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.3% (1067 of 1119 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Albanian)

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Albanian)

Currently translated at 99.5% (1120 of 1125 strings)

feat(lang): translated using Weblate (Albanian)

Currently translated at 100.0% (1119 of 1119 strings)

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

Co-authored-by: Joseph Valderrama Palacios <jvalderrama.es@gmail.com>
Co-authored-by: DragoPrime <emperordrago@gmail.com>
Co-authored-by: Sergiu Pahontu <pahontusergiu@gmail.com>
Co-authored-by: Patryk <byakurau1@gmail.com>
Co-authored-by: Sergey Moiseev <ty4ko@bk.ru>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Eric <hamburger1024@mailbox.org>
Co-authored-by: lkw123 <2020393267@qq.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Co-authored-by: TheCatLady <o40yoym9@anonaddy.me>
Co-authored-by: Squizzy911 <tobias.mueller1210@gmail.com>
Co-authored-by: inkarnation <94744834+inkarnation@users.noreply.github.com>
Co-authored-by: exentler <gurandsrud@gmail.com>
Co-authored-by: Micke Nilsson <mikni@proton.me>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Izik Avinoam <izik.avi@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: lpispek <lpispek@gmail.com>
Co-authored-by: Denis Çerri <deniscerri3@gmail.com>
2023-01-05 13:57:43 +09:00
renovate[bot]
19b4dc424f chore(deps): update all non-major dependencies (#3195) [skip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-05 13:35:13 +09:00
renovate[bot]
7fef48df63 chore(deps): update dependency lint-staged to v13 (#2931) [skip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-05 13:25:24 +09:00
allcontributors[bot]
8220ea55ae docs: add ceptonit as a contributor for doc (#3211) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-01-05 13:19:25 +09:00
ceptonit
4de4a1a52c docs: removed 'available' from /request/{requestId}/{status} endpoint (#3098) [skip ci] 2023-01-05 04:18:05 +00:00
Ryan Cohen
042a1a950f fix: update StatusBadgeMini to shrink on title cards (and remove ring) (#3210) 2023-01-05 12:09:08 +09:00
renovate[bot]
421029ebab fix(deps): update dependency axios to v1 (#3202)
* fix(deps): update dependency axios to v1

* fix: deal with possibly undefined headers from axios

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: sct <ryan@sct.dev>
2023-01-05 11:05:46 +09:00
Ryan Cohen
4e9be7a3f7 fix: correct link to correct keyword results for series (#3208) 2023-01-05 10:30:05 +09:00
Fallenbagel
af4a3b4279 Merge remote-tracking branch 'notfakie/develop' into develop 2023-01-05 03:47:35 +05:00
Fallenbagel
5ce59cc2ee Revert to develop version [skip ci] 2023-01-05 03:45:57 +05:00
Fallenbagel
ff2fa66002 Merge pull request #291 from Fallenbagel/main
Merge origin/main to develop
2023-01-05 03:38:47 +05:00
Fallenbagel
9388a1e61c docs: fix CHANGELOG formatting issue 2023-01-05 02:51:45 +05:00
Fallenbagel
b44a1b4a99 Remove CHANGELOG from origin/develop [skip ci] 2023-01-05 02:46:59 +05:00
Fallenbagel
6f73dbc36a Fix snap stable builds 2023-01-05 02:26:23 +05:00
Ryan Cohen
9d3446d370 fix: restore status badges on titles on actors page when hide available media enabled (#3206) 2023-01-04 17:29:48 +09:00
Ryan Cohen
2f680b4cec refactor: update titlecard to use StatusBadgeMini (#3205) 2023-01-04 16:40:21 +09:00
Brandon Cohen
2179637d43 fix: series displayed an empty season with series list/request modal (#3147)
* fix: series would show an empty season on season list or tv request modal

* fix: request more would show even if all requestable seasons are already requested

* fix: will check if request or season length is longer
2023-01-04 05:48:21 +00:00
Ryan Cohen
e084649878 feat: add keywords to movie/series detail pages (#3204) 2023-01-04 14:19:51 +09:00
renovate[bot]
edf5010659 chore(deps): update dependency cypress to v12 (#3197) [skip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-04 10:43:02 +09:00
Ryan Cohen
93afead92e fix: convert genre/studio to string in create slider (#3201)
* fix: convert genre/studio to string in create slider

* fix: fix typo in variable name for i18n message
2023-01-04 10:42:30 +09:00
renovate[bot]
dd48d59b20 fix(deps): update dependency @heroicons/react to v2 (#2970)
* fix(deps): update dependency @heroicons/react to v2

* fix: update imports and fix icon name changes for heroicons

* fix: also update MiniStatusBadge to use new check icon

* fix: update last place with old import

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: sct <ryan@sct.dev>
2023-01-04 01:06:02 +00:00
renovate[bot]
c4b16abc62 fix(deps): pin dependency @headlessui/react to 1.7.7 (#3194) [skip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-03 22:08:29 +09:00
renovate[bot]
1a95d423f2 chore(deps): update all non-major dependencies (#2926)
* chore(deps): update all non-major dependencies

* fix: correct breaking changes

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: sct <ryan@sct.dev>
2023-01-03 22:06:07 +09:00
Ryan Cohen
cd3574851a feat: add discover customization (#3182) 2023-01-03 16:04:28 +09:00
semantic-release-bot
299f65c597 chore(release): 1.3.0 2023-01-02 00:14:11 +00:00
Fallenbagel
6021d1e336 Merge pull request #285 from Fallenbagel/prepare-for-next-version
Merge origin/develop to prepare for next release
2023-01-02 05:11:37 +05:00
Fallenbagel
3ce1ef350e docs: merge CHANGELOG.md of develop with main to fix format issue 2023-01-02 05:01:13 +05:00
Fallenbagel
06c91744f3 Merge branch 'develop' 2023-01-02 04:44:05 +05:00
Ryan Cohen
f14d9407d8 chore: update to use github codeql (#3191) [skip ci] 2022-12-30 11:38:08 +09:00
Brandon Cohen
68223f4b1e fix: add bg-opacity to in-progress status badges (#3190) 2022-12-30 11:36:47 +09:00
Ryan Cohen
76335ec8d3 refactor: update mini status icons on titlecard to match badge colors (#3188) 2022-12-29 23:10:23 +09:00
notfakie
2714cbcefd Merge remote-tracking branch 'overseerr/develop' into develop 2022-12-29 13:37:07 +13:00
Brandon Cohen
3309f77aa4 fix: added download status and title to request card/item error components (#3186) 2022-12-28 05:57:11 +00:00
Brandon Cohen
6face8cc45 fix: tooltip shows properly if not in progress (#3185) 2022-12-27 15:17:32 +00:00
Brandon Cohen
27feeea691 fix: changed overflow scroll to only if necessary (#3184) 2022-12-27 13:54:17 +09:00
Brandon Cohen
03853a1b91 feat(ui): request card progress bar (#3123) 2022-12-27 02:13:57 +00:00
Ryan Cohen
357cab87ac fix(experimental): use new RT API (sorta) (#3179) 2022-12-26 16:42:08 +09:00
Fallenbagel
d18e3d185f Merge pull request #277 from Fallenbagel/updatereadme
docs: update current features and add emphasis on the pre-requisites
2022-12-17 17:40:31 +05:00
Fallenbagel
e222463a63 docs: update current features and add emphasis on the pre-requisites [skip ci] 2022-12-17 06:02:06 +05:00
Fallenbagel
03b9bda287 Merge pull request #276 from Fallenbagel/fix-issue-#254
fix(ui): adds mediaServerName to statusBadge and manageSlideOver
2022-12-17 05:38:21 +05:00
Fallenbagel
7e20c7cb78 fix(locale): fix the duplicated wording in the Clear Media Warning message
Fixes the duplicated wording in the clear media warning message of manageSlideOver that was
introduced in previous commit
2022-12-17 05:10:11 +05:00
Fallenbagel
d0cdce9e90 fix(ui): adds mediaServerName to statusBadge and manageSlideOver
Adds mediaServerName to statusBadge and manageSlideOver to indicate the type of mediaServer that is
connected to jellyseerr

fix #254
2022-12-17 05:02:47 +05:00
Fallenbagel
113b09bf2b Merge pull request #275 from Fallenbagel/support-mixed-libraries
feat(api): adds support for Mixed Libraries
2022-12-17 04:27:46 +05:00
Fallenbagel
b16f192b92 Merge pull request #274 from Fallenbagel/pr269
Merge upstream "origin/develop"
2022-12-16 22:26:21 +05:00
Fallenbagel
d9ca3c6e52 fix(api): ignore Music,Books,Photos,MusicVideo libraries
Ignores libraries other than tvshows,movies,others
2022-12-16 20:19:03 +05:00
Fallenbagel
ba82ecec5c feat(api): adds support for Mixed Libraries
Adds support for mixed libraries with movies and show types

fix #95
2022-12-16 16:23:32 +05:00
Fallenbagel
c052a2455c Merge remote-tracking branch 'origin/develop' into pr269 2022-12-16 13:01:07 +05:00
Fallenbagel
2d99a8b03c fix character length of summary for snaps 2022-12-16 12:34:59 +05:00
Fallenbagel
7434c0cf2f fix formatting snapcraft 2022-12-16 12:22:22 +05:00
notfakie
afcb096f49 Merge remote-tracking branch 'overseerr/develop' into develop 2022-12-16 19:58:33 +13:00
Fallenbagel
9dc11cedbf Merge pull request #272 from Fallenbagel/prepare-snap-builds [skip ci]
Prepare snap builds [skip ci]
2022-12-16 03:01:31 +05:00
Fallenbagel
22aab783d4 Prepare snap builds [skip ci] 2022-12-16 02:41:05 +05:00
Fallenbagel
a2babb83ad Merge pull request #263 from darmiel/fix/combined-episodes
fix: count combined episodes
2022-12-06 17:35:02 +05:00
allcontributors[bot]
76a7ceb758 docs: add s0up4200 as a contributor for doc (#3153) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-12-06 12:49:19 +04:00
soup
9688acaa87 chore(docs): fix typo in fail2ban article (#3139) [skip ci] 2022-12-06 12:35:09 +04:00
darmiel
64339e5f03 fix: count combined episodes
Jellyfin allows combined episodes, like `S01E01-E02`,
but seasons containing such episodes are only recognized
as `Partially Available`. This commit should fix that.
2022-12-04 00:36:01 +01:00
Fallenbagel
1ceea3dcca Merge pull request #262 from Fallenbagel/change-cypress-projectid
test(cypress): change cypress projectId
2022-12-03 17:10:42 +05:00
Fallenbagel
e3c3283603 test(cypress): change cypress projectId
Change cypress projectId to jellyseerr's project id
2022-12-03 17:07:36 +05:00
Fallenbagel
4ac02d3aac docs(readme): fixed the formatting of README.md
Fixed the formatting of README.md that was causing issues with the formatting check workflow
2022-12-03 16:15:14 +05:00
Fallenbagel
8eacfe045f Add information about unsupported types
Added information about unsupported libraries and automatic grouping
2022-11-24 06:06:35 +05:00
Ryan Cohen
15e246929b fix(api): handle auth for accounts where the plex id may have been set to null (#3125)
also made some changes to hopefully alleviate this issue from happening at all in the future
2022-11-20 19:07:32 +09:00
Fallenbagel
c1424634fb fix the git clone url 2022-11-02 03:31:47 +05:00
Brandon Cohen
07ec3efbca fix: improved PTR scrolling performance (#3095) 2022-11-01 14:24:10 +09:00
Fallenbagel
9b07b10901 style(readme): fix formatting of README.md 2022-10-26 08:43:08 +05:00
Fallenbagel
b1e9cdbea2 add in minimum version needed for nodejs 2022-10-25 03:53:50 +05:00
Fallenbagel
9aee630392 update instructions to include steps for stable version 2022-10-25 03:47:08 +05:00
Fallenbagel
6b50f77624 update windows instructions
Replaced `yarn` with `npm` in the installation of `win-node-env`
2022-10-25 03:45:07 +05:00
Fallenbagel
16f1c286c4 Add detailed native installation instructions 2022-10-25 03:42:46 +05:00
TheCatLady
64aab6dd82 feat(lang): add Croatian display language (#3041) 2022-10-19 00:40:03 +00:00
allcontributors[bot]
144bb84bdc docs: add Eclipseop as a contributor for code (#3087) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-10-19 09:29:07 +09:00
Mackenzie
76260f9b22 build: update semantic-release to use proper arg for git sha (#3075) 2022-10-18 23:18:48 +00:00
Ryan Cohen
500cd1f872 feat: custom image proxy (#3056) 2022-10-18 14:40:24 +09:00
semantic-release-bot
9252817b58 chore(release): 1.2.1 2022-10-18 02:24:18 +00:00
Fallenbagel
a66925067d Merge pull request #242 from Fallenbagel/develop
Merge origin/develop
2022-10-18 07:21:19 +05:00
Fallenbagel
d037d178aa Merge pull request #240 from sambartik/revert-230-fix-jellyfin-emby-links
Fix jellyfin external url basepath being ignored
2022-10-16 04:24:25 +05:00
Fallenbagel
ab09664d41 fix(backend): fix jellyfinHost to not be undefined
Fix jellyfinHost so its not being treated as null or undefined

fix #237
2022-10-16 03:50:22 +05:00
Brandon Cohen
bfe56c3470 fix: added deep links to issues and status badges (#3065) 2022-10-15 05:39:33 +00:00
TheCatLady
1dfa9431a9 fix: update API docs to allow 'all' seasons value (#3073) 2022-10-15 08:31:50 +09:00
Samuel Bartík
0faae20bac Fix jellyfin external url basepath being ignored 2022-10-13 23:24:09 +02:00
Samuel Bartík
5b10da4073 Revert "fix(backend): fixes Jellyfin/Emby links if server is initially setup with a trailing /" 2022-10-13 23:08:20 +02:00
209 changed files with 14098 additions and 6828 deletions

View File

@@ -737,6 +737,42 @@
"contributions": [
"translation"
]
},
{
"login": "Eclipseop",
"name": "Mackenzie",
"avatar_url": "https://avatars.githubusercontent.com/u/5846213?v=4",
"profile": "https://github.com/Eclipseop",
"contributions": [
"code"
]
},
{
"login": "s0up4200",
"name": "soup",
"avatar_url": "https://avatars.githubusercontent.com/u/18177310?v=4",
"profile": "https://github.com/s0up4200",
"contributions": [
"doc"
]
},
{
"login": "ceptonit",
"name": "ceptonit",
"avatar_url": "https://avatars.githubusercontent.com/u/12678743?v=4",
"profile": "https://github.com/ceptonit",
"contributions": [
"doc"
]
},
{
"login": "aedelbro",
"name": "aedelbro",
"avatar_url": "https://avatars.githubusercontent.com/u/36162221?v=4",
"profile": "https://github.com/aedelbro",
"contributions": [
"code"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
@@ -745,5 +781,6 @@
"projectOwner": "sct",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": false
"skipCi": false,
"commitConvention": "angular"
}

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

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

View File

@@ -91,9 +91,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
echo "status=failure" >> $GITHUB_OUTPUT
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

41
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: 'CodeQL'
on:
push:
branches: ['develop']
pull_request:
branches: ['develop']
schedule:
- cron: '50 7 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [javascript]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: '/language:${{ matrix.language }}'

View File

@@ -14,7 +14,7 @@ jobs:
uses: actions/checkout@v3
- name: Get the version
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx

View File

@@ -51,8 +51,8 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Switch to master branch
run: git checkout master
- name: Switch to main branch
run: git checkout main
- name: Pull latest changes
run: git pull
- name: Prepare
@@ -60,9 +60,9 @@ jobs:
run: |
git fetch --prune --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo ::set-output name=RELEASE::stable
echo "RELEASE=stable" >> $GITHUB_OUTPUT
else
echo ::set-output name=RELEASE::edge
echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v1
@@ -76,7 +76,7 @@ jobs:
- name: Upload Snap Package
uses: actions/upload-artifact@v2
with:
name: overseerr-snap-package-${{ matrix.architecture }}
name: jellyseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1
@@ -84,8 +84,9 @@ jobs:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
with:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
@@ -102,9 +103,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
echo "status=failure" >> $GITHUB_OUTPUT
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

View File

@@ -35,9 +35,9 @@ jobs:
run: |
git fetch --prune --unshallow --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo ::set-output name=RELEASE::stable
echo "RELEASE=stable" >> $GITHUB_OUTPUT
else
echo ::set-output name=RELEASE::edge
echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v2
@@ -49,7 +49,7 @@ jobs:
- name: Upload Snap Package
uses: actions/upload-artifact@v3
with:
name: overseerr-snap-package-${{ matrix.architecture }}
name: jellyseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1
@@ -57,8 +57,9 @@ jobs:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
with:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
@@ -75,9 +76,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
echo "status=failure" >> $GITHUB_OUTPUT
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

3
.gitignore vendored
View File

@@ -67,3 +67,6 @@ tsconfig.tsbuildinfo
# Webstorm
.idea
# Config Cache Directory
config/cache

View File

@@ -1,88 +1,77 @@
# [1.2.0](https://github.com/fallenbagel/jellyseerr/compare/v1.1.1...v1.2.0) (2022-10-12)
# [1.4.0](https://github.com/fallenbagel/jellyseerr/compare/v1.3.0...v1.4.0) (2023-01-29)
### Bug Fixes
* **api:** add rate limiter to TMDb requests to hopefully deal with 429s ([#2881](https://github.com/fallenbagel/jellyseerr/issues/2881)) ([aed1409](https://github.com/fallenbagel/jellyseerr/commit/aed1409f29d5b6360e87381d78dfeb4cc86d6fc6)), closes [#2853](https://github.com/fallenbagel/jellyseerr/issues/2853)
* **api:** ignore filter if unset in media route ([#2647](https://github.com/fallenbagel/jellyseerr/issues/2647)) ([a6c1f3f](https://github.com/fallenbagel/jellyseerr/commit/a6c1f3f7ce498e32817cc8c74d439e8d99d6fbf4))
* **api:** lookup shows using english title only ([#2911](https://github.com/fallenbagel/jellyseerr/issues/2911)) ([004e1bb](https://github.com/fallenbagel/jellyseerr/commit/004e1bb17e14bef1697b6b993f6ff92b77cdaeb4)), closes [#2801](https://github.com/fallenbagel/jellyseerr/issues/2801)
* **api:** use correct path param type in openapi spec ([#2834](https://github.com/fallenbagel/jellyseerr/issues/2834)) ([6cd0c9b](https://github.com/fallenbagel/jellyseerr/commit/6cd0c9b2c81db2728ce09a9bc273a9aee366acbc))
* **backend:** fixes Jellyfin/Emby links if server is initially setup with a trailing / ([6635701](https://github.com/fallenbagel/jellyseerr/commit/66357019f034ffac342ebd49a32bd75c95d4dec9)), closes [#168](https://github.com/fallenbagel/jellyseerr/issues/168) [#220](https://github.com/fallenbagel/jellyseerr/issues/220)
* better ordering of RequestButton options & properly handle failed requests ([#2944](https://github.com/fallenbagel/jellyseerr/issues/2944)) ([c143c0b](https://github.com/fallenbagel/jellyseerr/commit/c143c0b8d285896a7ad5f42f2a7dc1e38b55cc3a))
* check perms to view watchlist slider on user profile ([#2980](https://github.com/fallenbagel/jellyseerr/issues/2980)) ([5d73bc2](https://github.com/fallenbagel/jellyseerr/commit/5d73bc22389a2606ecc630f88b6ec2328f690975))
* clicking outside modal closes modal again ([#2984](https://github.com/fallenbagel/jellyseerr/issues/2984)) ([1a00532](https://github.com/fallenbagel/jellyseerr/commit/1a0053221b58d198bdcca0249fcd76226122c6a2))
* compatibility issue with safari ([#3019](https://github.com/fallenbagel/jellyseerr/issues/3019)) ([e486623](https://github.com/fallenbagel/jellyseerr/commit/e486623310b1c0de12a5b38bf934543fde250c37))
* correct safe margin for slideover ([#2977](https://github.com/fallenbagel/jellyseerr/issues/2977)) ([23409e6](https://github.com/fallenbagel/jellyseerr/commit/23409e6f2ffb060e7914db28375e141a6087ddfa))
* correct spacing on season header badges ([#2983](https://github.com/fallenbagel/jellyseerr/issues/2983)) ([c4aa08f](https://github.com/fallenbagel/jellyseerr/commit/c4aa08f5f0bcc856e993416936cc74e4db6aed5d))
* **deps:** do not list email-validator as a devDependency ([9518cb3](https://github.com/fallenbagel/jellyseerr/commit/9518cb36357cd36b4b75439ac74ba910faa9a217))
* **deps:** pin dependencies ([#2946](https://github.com/fallenbagel/jellyseerr/issues/2946)) [skip ci] ([103c4ca](https://github.com/fallenbagel/jellyseerr/commit/103c4ca49ca6f7a8101a18a540d3955a1ac6eae7))
* **deps:** pin dependency @formatjs/intl-utils to 3.8.4 ([#2975](https://github.com/fallenbagel/jellyseerr/issues/2975)) [skip ci] ([baf1ea9](https://github.com/fallenbagel/jellyseerr/commit/baf1ea95a38537fab56cc324aa9359f40b615fe6))
* **deps:** pin dependency @headlessui/react to v0.0.0-insiders.b301f04 ([#2993](https://github.com/fallenbagel/jellyseerr/issues/2993)) [skip ci] ([833f52d](https://github.com/fallenbagel/jellyseerr/commit/833f52de56cc4715673c5d2593f05528d260ae15))
* **deps:** pin dependency cronstrue to 2.11.0 ([#3018](https://github.com/fallenbagel/jellyseerr/issues/3018)) [skip ci] ([f20ba3f](https://github.com/fallenbagel/jellyseerr/commit/f20ba3fc2e6cd42c2c73ba47812d9d177e75a8c2))
* **deps:** pin dependency react-popper-tooltip to 4.4.2 ([#2952](https://github.com/fallenbagel/jellyseerr/issues/2952)) [skip ci] ([5247f14](https://github.com/fallenbagel/jellyseerr/commit/5247f14968c1f5a1a2efdc26f850cf81c0c04f0e))
* do not display 'Request More' button if no requestable seasons ([#2998](https://github.com/fallenbagel/jellyseerr/issues/2998)) ([23f93e3](https://github.com/fallenbagel/jellyseerr/commit/23f93e311d3c4ed413b2fabca520b5c97869c9b8))
* failure to load SearchByNameModal ([#3000](https://github.com/fallenbagel/jellyseerr/issues/3000)) ([410ad0d](https://github.com/fallenbagel/jellyseerr/commit/410ad0d4b40e2ae3ca315a9c808a886ab2c7bf2d))
* fix play on Jellyfin/Emby button after previous merge ([3b0c091](https://github.com/fallenbagel/jellyseerr/commit/3b0c0915fb8e1ad671227f60cc181d52f226ad6b))
* **frontend:** better request/media cards for items without valid TMDb IDs ([#2181](https://github.com/fallenbagel/jellyseerr/issues/2181)) ([9bc1f89](https://github.com/fallenbagel/jellyseerr/commit/9bc1f8977707f2d3383a7af8efa0521ced096777))
* **frontend:** only allow 'request as' users w/ request perms ([#2991](https://github.com/fallenbagel/jellyseerr/issues/2991)) ([dbdecb1](https://github.com/fallenbagel/jellyseerr/commit/dbdecb1e0afb450e0cda6965a6d89408a831db0e))
* **import statement:** import statement ([8724058](https://github.com/fallenbagel/jellyseerr/commit/8724058aa568b5eb96420c28f9082ca8a9ed714f))
* issues and login page still had incorrect animations ([#2979](https://github.com/fallenbagel/jellyseerr/issues/2979)) ([095048d](https://github.com/fallenbagel/jellyseerr/commit/095048d94af28f25f88f956aba8e01d1199082f3))
* **lang:** correct capitalization of 'TMDB' ([#2953](https://github.com/fallenbagel/jellyseerr/issues/2953)) ([9021696](https://github.com/fallenbagel/jellyseerr/commit/9021696cf085bd6387701811e64466e14893d1db))
* **lang:** manage movie -> manage series ([#2963](https://github.com/fallenbagel/jellyseerr/issues/2963)) ([f5e6b62](https://github.com/fallenbagel/jellyseerr/commit/f5e6b620c133e38cf18978a619d3d4757bbab755))
* log level value should not be case sensitive ([#2913](https://github.com/fallenbagel/jellyseerr/issues/2913)) ([6428b8d](https://github.com/fallenbagel/jellyseerr/commit/6428b8d4195c736a384441a761a746a35a3ad009))
* new status indicators added to series list on mobile ([#3024](https://github.com/fallenbagel/jellyseerr/issues/3024)) ([407af32](https://github.com/fallenbagel/jellyseerr/commit/407af32d32a30d23ee14a9ff763cb4aa582d3ede))
* only request Tautulli watch data for Plex media servers (to avoid error messages in logs) ([6dbb99e](https://github.com/fallenbagel/jellyseerr/commit/6dbb99e0b685c6a0e7c9cb62c300ceee85b43bf4))
* **plex:** add container-size header to recently added api call ([#3023](https://github.com/fallenbagel/jellyseerr/issues/3023)) ([d8da5cb](https://github.com/fallenbagel/jellyseerr/commit/d8da5cbe9d0700e02cbece70caf9103bc5376505))
* remove backdrop-blur class from warning buttons ([#3037](https://github.com/fallenbagel/jellyseerr/issues/3037)) ([430b1ab](https://github.com/fallenbagel/jellyseerr/commit/430b1ab871f8e3eefbebb37c74aa1ce3f0862efe))
* remove failing ci job that builds a test copy to a private repo ([5f7e7ee](https://github.com/fallenbagel/jellyseerr/commit/5f7e7eef119c1ba2fb7b1cb8c5e06f372b92ac7f))
* scroll restoration ([#3005](https://github.com/fallenbagel/jellyseerr/issues/3005)) ([14280c5](https://github.com/fallenbagel/jellyseerr/commit/14280c54370fd9a3e73120e4208e36183d39f9a3))
* settings log modal when closing ([#2985](https://github.com/fallenbagel/jellyseerr/issues/2985)) ([4d56320](https://github.com/fallenbagel/jellyseerr/commit/4d563208709fe581ef6bf475e969197d586d907b))
* sidebar close button placement when using PWA ([#3045](https://github.com/fallenbagel/jellyseerr/issues/3045)) ([21d20fd](https://github.com/fallenbagel/jellyseerr/commit/21d20fdfd61b7a5a2ec265d420aec103b1430a06))
* start scheduled jobs on initial admin account setup ([b080251](https://github.com/fallenbagel/jellyseerr/commit/b08025195e1e1fd07e274b6846728770ec1d3e18)), closes [#170](https://github.com/fallenbagel/jellyseerr/issues/170)
* transition animation ([#2974](https://github.com/fallenbagel/jellyseerr/issues/2974)) ([98028bf](https://github.com/fallenbagel/jellyseerr/commit/98028bf2f4d29175fa0922e744fee168849d653f))
* **ui:** hide 'Recently Added' & 'Recent Requests' sliders when empty ([#2190](https://github.com/fallenbagel/jellyseerr/issues/2190)) ([03d5e56](https://github.com/fallenbagel/jellyseerr/commit/03d5e56678c3a372114a0256ee3431deee42cb57))
* **ui:** hide null dates in episodes list ([#3035](https://github.com/fallenbagel/jellyseerr/issues/3035)) ([7404d68](https://github.com/fallenbagel/jellyseerr/commit/7404d68143e830df73b9d2779a6d7ea65bc9fd4f))
* **ui:** minor fixes ([#3036](https://github.com/fallenbagel/jellyseerr/issues/3036)) ([f5c2fc1](https://github.com/fallenbagel/jellyseerr/commit/f5c2fc1c209b2d04f0e39a97d8b65bcac00667dc))
* **ui:** remove 'all' badge from request cards ([#2992](https://github.com/fallenbagel/jellyseerr/issues/2992)) ([5c01313](https://github.com/fallenbagel/jellyseerr/commit/5c01313cc4c8277de398124848b4481a2b0080b3))
* update Discord ID regex to include 19 digit IDs ([#2860](https://github.com/fallenbagel/jellyseerr/issues/2860)) ([9da8461](https://github.com/fallenbagel/jellyseerr/commit/9da84612254fe0c36b38bc49184cc1fc52ff6212))
* use fallbackData to prepare user data during SSR ([#2968](https://github.com/fallenbagel/jellyseerr/issues/2968)) ([6e60a27](https://github.com/fallenbagel/jellyseerr/commit/6e60a275c7c2451beb1922d66eaa6c5411d36c3d))
* use image.tmdb.org for setup/login backdrop images ([#2966](https://github.com/fallenbagel/jellyseerr/issues/2966)) ([3b26338](https://github.com/fallenbagel/jellyseerr/commit/3b2633812b5ca8cde010b3f8c2ea9a8646b33e1d))
* username will not show undefined on cancel or delete ([#2982](https://github.com/fallenbagel/jellyseerr/issues/2982)) ([b925857](https://github.com/fallenbagel/jellyseerr/commit/b925857dfa27483558eab24bc5f20f654e294a08))
* watch data not required to show Tautulli button ([#2976](https://github.com/fallenbagel/jellyseerr/issues/2976)) ([dd28200](https://github.com/fallenbagel/jellyseerr/commit/dd282000407ae9656640f38561a104916d12571a))
* add bg-opacity to in-progress status badges ([#3190](https://github.com/fallenbagel/jellyseerr/issues/3190)) ([68223f4](https://github.com/fallenbagel/jellyseerr/commit/68223f4b1e98b01825516dcba39cbb2d3df31a70))
* added download status and title to request card/item error components ([#3186](https://github.com/fallenbagel/jellyseerr/issues/3186)) ([3309f77](https://github.com/fallenbagel/jellyseerr/commit/3309f77aa4be1d70b27693531c119a8e26822518))
* arrow icons were misplaced on mobile in slider edit ([#3260](https://github.com/fallenbagel/jellyseerr/issues/3260)) ([d328485](https://github.com/fallenbagel/jellyseerr/commit/d328485161b9cae6a70ef0713b4878207bc6015e))
* **build:** update usage of publish snap action ([#3272](https://github.com/fallenbagel/jellyseerr/issues/3272)) ([51b05cd](https://github.com/fallenbagel/jellyseerr/commit/51b05cd8fbb5d332807d8c00b2ffb7b10c3d0179))
* changed overflow scroll to only if necessary ([#3184](https://github.com/fallenbagel/jellyseerr/issues/3184)) ([27feeea](https://github.com/fallenbagel/jellyseerr/commit/27feeea69121336557deda1f32b65a5daa146f82))
* convert genre/studio to string in create slider ([#3201](https://github.com/fallenbagel/jellyseerr/issues/3201)) ([93afead](https://github.com/fallenbagel/jellyseerr/commit/93afead92e497f2e5bce67a34fffdaa08d20c7f2))
* correct checkbox position (again) for slider edits ([#3227](https://github.com/fallenbagel/jellyseerr/issues/3227)) ([3ba6df1](https://github.com/fallenbagel/jellyseerr/commit/3ba6df1a41c084c4a6a90354338047623abef521))
* correct grid sizing for webkit on streaming services ([#3248](https://github.com/fallenbagel/jellyseerr/issues/3248)) ([6fd11cf](https://github.com/fallenbagel/jellyseerr/commit/6fd11cf4254e1a19310592bec78a6de52bc073a8))
* correct issue detail bottom padding on mobile displays ([#3268](https://github.com/fallenbagel/jellyseerr/issues/3268)) ([3db010b](https://github.com/fallenbagel/jellyseerr/commit/3db010b9eaec62aa08d973a61caf1801471bbf3e))
* correct link to correct keyword results for series ([#3208](https://github.com/fallenbagel/jellyseerr/issues/3208)) ([4e9be7a](https://github.com/fallenbagel/jellyseerr/commit/4e9be7a3f7304ee7be5ee6fd34b1ea8f6c0cf399))
* correct spacing between sliders ([#3225](https://github.com/fallenbagel/jellyseerr/issues/3225)) ([62e2de7](https://github.com/fallenbagel/jellyseerr/commit/62e2de70bf37b72d5f63370b662d4103a642775b))
* correctly check mobile menu permissions ([#3271](https://github.com/fallenbagel/jellyseerr/issues/3271)) ([f4a22dc](https://github.com/fallenbagel/jellyseerr/commit/f4a22dc437404558f301ccfc195cf0a300dd1ff2))
* correctly restore selected streaming service filters ([#3249](https://github.com/fallenbagel/jellyseerr/issues/3249)) ([154f3e7](https://github.com/fallenbagel/jellyseerr/commit/154f3e72efbf0b663358b3029156f54516f01a2f))
* create shared class to add bottom spacing ([#3269](https://github.com/fallenbagel/jellyseerr/issues/3269)) ([5d1c6f7](https://github.com/fallenbagel/jellyseerr/commit/5d1c6f706555613d97ed9e61d8b665543c2f239b))
* **deps:** pin dependency @headlessui/react to 1.7.7 ([#3194](https://github.com/fallenbagel/jellyseerr/issues/3194)) [skip ci] ([c4b16ab](https://github.com/fallenbagel/jellyseerr/commit/c4b16abc62647c74215155942a4230a31a238677))
* **deps:** update dependency @heroicons/react to v2 ([#2970](https://github.com/fallenbagel/jellyseerr/issues/2970)) ([dd48d59](https://github.com/fallenbagel/jellyseerr/commit/dd48d59b20e2d1800ea30912116f4a4f1bb7928f))
* **deps:** update dependency axios to v1 ([#3202](https://github.com/fallenbagel/jellyseerr/issues/3202)) ([421029e](https://github.com/fallenbagel/jellyseerr/commit/421029ebab66c9a6622ba47e56d7f6473524cce4))
* **deps:** update dependency swr to v2 ([#3212](https://github.com/fallenbagel/jellyseerr/issues/3212)) ([7b6db50](https://github.com/fallenbagel/jellyseerr/commit/7b6db50ae55b1fc60d19a5cff62dd46bb989fa51))
* **experimental:** use new RT API (sorta) ([#3179](https://github.com/fallenbagel/jellyseerr/issues/3179)) ([357cab8](https://github.com/fallenbagel/jellyseerr/commit/357cab87ac7752b8e119b51c938b343c661d83c2))
* improve small screen layout for discover editing ([#3221](https://github.com/fallenbagel/jellyseerr/issues/3221)) ([d23b213](https://github.com/fallenbagel/jellyseerr/commit/d23b2132de05f072f7f9daad83d81421d747cf99))
* include new package calendar css in build ([#3235](https://github.com/fallenbagel/jellyseerr/issues/3235)) ([c2a1a20](https://github.com/fallenbagel/jellyseerr/commit/c2a1a20a3bb20039a1936c7fe0ecb9e8311a0aea))
* issues with issues ([#3267](https://github.com/fallenbagel/jellyseerr/issues/3267)) ([fd21971](https://github.com/fallenbagel/jellyseerr/commit/fd219717c01c558814d7a80de6304272b5a7944e))
* multiple genre filtering now works ([#3282](https://github.com/fallenbagel/jellyseerr/issues/3282)) ([5076938](https://github.com/fallenbagel/jellyseerr/commit/507693881b939819413f0959df5ef6b7a357eb5c))
* prevent double encode if we are on /search endpoint ([#3238](https://github.com/fallenbagel/jellyseerr/issues/3238)) ([a343f8a](https://github.com/fallenbagel/jellyseerr/commit/a343f8ad915491a9c81512c7e541a1dac8906025))
* **request:** approve request when retrying request ([#3234](https://github.com/fallenbagel/jellyseerr/issues/3234)) ([b515701](https://github.com/fallenbagel/jellyseerr/commit/b5157010c46cd9083993d5ee0172007b83d631da))
* **request:** mark request as approved if media is already available when retrying failed request ([#3244](https://github.com/fallenbagel/jellyseerr/issues/3244)) ([cb65074](https://github.com/fallenbagel/jellyseerr/commit/cb650745f6a33e69391a633e6d272831f314e098))
* restore border to ghost button and fix discover slider visibility toggle position ([#3226](https://github.com/fallenbagel/jellyseerr/issues/3226)) ([2eebb7f](https://github.com/fallenbagel/jellyseerr/commit/2eebb7fd3941b34fe9472aaf9d28265df8cce311))
* restore status badges on titles on actors page when hide available media enabled ([#3206](https://github.com/fallenbagel/jellyseerr/issues/3206)) ([9d3446d](https://github.com/fallenbagel/jellyseerr/commit/9d3446d370499c3251159393e5c791b01225e05c))
* screen would zoom on mobile if date picker input was selected ([#3241](https://github.com/fallenbagel/jellyseerr/issues/3241)) ([3aefddd](https://github.com/fallenbagel/jellyseerr/commit/3aefddd48834d86150d5f5cceb2d08af3a78847b))
* series displayed an empty season with series list/request modal ([#3147](https://github.com/fallenbagel/jellyseerr/issues/3147)) ([2179637](https://github.com/fallenbagel/jellyseerr/commit/2179637d437999290eaa4152f6f37c71fc3d8ba3))
* tooltip shows properly if not in progress ([#3185](https://github.com/fallenbagel/jellyseerr/issues/3185)) ([6face8c](https://github.com/fallenbagel/jellyseerr/commit/6face8cc4564b978fb98af32659b326d8c5cede8))
* **ui:** series first air date sorting ([#3283](https://github.com/fallenbagel/jellyseerr/issues/3283)) ([374c78c](https://github.com/fallenbagel/jellyseerr/commit/374c78c989cc86bb144a954a91d5d183c4b591c0))
* update StatusBadgeMini to shrink on title cards (and remove ring) ([#3210](https://github.com/fallenbagel/jellyseerr/issues/3210)) ([042a1a9](https://github.com/fallenbagel/jellyseerr/commit/042a1a950fdd4d4a61edf4bc19657f9b7a526da8))
### Features
* add 20th Century Studios to Studio Slider ([#2288](https://github.com/fallenbagel/jellyseerr/issues/2288)) ([b33956e](https://github.com/fallenbagel/jellyseerr/commit/b33956e6b85b2c2fc12e23951c06f36e009fb627))
* **frontend:** a few more tooltips ([#2972](https://github.com/fallenbagel/jellyseerr/issues/2972)) ([815d709](https://github.com/fallenbagel/jellyseerr/commit/815d709bcfa4cca1528cc697defe9cd773ea0089))
* **frontend:** add more tooltips ([#2961](https://github.com/fallenbagel/jellyseerr/issues/2961)) ([950b171](https://github.com/fallenbagel/jellyseerr/commit/950b1712b7449dc48a9c1589950907dd581f069a))
* improved user dropdown ([#2969](https://github.com/fallenbagel/jellyseerr/issues/2969)) ([67f3a38](https://github.com/fallenbagel/jellyseerr/commit/67f3a3829e2f629e72ec3c32b042be2ef08c38e9))
* **jobs:** show current job frequency in edit modal ([#3008](https://github.com/fallenbagel/jellyseerr/issues/3008)) ([99fc9a2](https://github.com/fallenbagel/jellyseerr/commit/99fc9a2da01b1628d5f849ce56f016c0ab26c3db))
* **lang:** add Arabic and Lithuanian display languages ([#2916](https://github.com/fallenbagel/jellyseerr/issues/2916)) ([3db3044](https://github.com/fallenbagel/jellyseerr/commit/3db3044210316817144ecacd81be97f159d0df2b))
* **lang:** translations update from Hosted Weblate ([#2659](https://github.com/fallenbagel/jellyseerr/issues/2659)) ([e939dc6](https://github.com/fallenbagel/jellyseerr/commit/e939dc678e124bd1f630c98d1d4927dba6372af5))
* **lang:** translations update from Hosted Weblate ([#2915](https://github.com/fallenbagel/jellyseerr/issues/2915)) ([a0301e2](https://github.com/fallenbagel/jellyseerr/commit/a0301e2d83b2fca07f69a2c274d4745edef6d0cd))
* **lang:** translations update from Hosted Weblate ([#2958](https://github.com/fallenbagel/jellyseerr/issues/2958)) ([29ab178](https://github.com/fallenbagel/jellyseerr/commit/29ab178fb0048c055a86bc40f7b47117903a3b2c))
* **lang:** translations update from Hosted Weblate ([#2971](https://github.com/fallenbagel/jellyseerr/issues/2971)) ([2950cf4](https://github.com/fallenbagel/jellyseerr/commit/2950cf4438e2d10a9c0543a848519a113527fe91))
* **lang:** translations update from Hosted Weblate ([#2999](https://github.com/fallenbagel/jellyseerr/issues/2999)) ([8672869](https://github.com/fallenbagel/jellyseerr/commit/867286996b805fefd3c16f2a1a05d5f2c10daced))
* **lang:** translations update from Hosted Weblate ([#3006](https://github.com/fallenbagel/jellyseerr/issues/3006)) ([611ceeb](https://github.com/fallenbagel/jellyseerr/commit/611ceeb5f49ec4760f157eafb03b2dae465d476e))
* **lang:** translations update from Hosted Weblate ([#3014](https://github.com/fallenbagel/jellyseerr/issues/3014)) ([3d458dd](https://github.com/fallenbagel/jellyseerr/commit/3d458dd2fdc8807f09af8b4c29bed794b3886f0f))
* **lang:** translations update from Hosted Weblate ([#3026](https://github.com/fallenbagel/jellyseerr/issues/3026)) ([16cb53f](https://github.com/fallenbagel/jellyseerr/commit/16cb53f703a6c42b99aa776515f90b3ed153b382))
* **language:** update czech language ([8619724](https://github.com/fallenbagel/jellyseerr/commit/8619724c652bfb0604373bcdb8c32a8e87a73fe7))
* **logs:** add search filter ([#2505](https://github.com/fallenbagel/jellyseerr/issues/2505)) ([30141f7](https://github.com/fallenbagel/jellyseerr/commit/30141f76e025763bf79fd3c8fb344d45519d5d8d))
* **notif:** auto-request notif type ([#2956](https://github.com/fallenbagel/jellyseerr/issues/2956)) ([6c0fd40](https://github.com/fallenbagel/jellyseerr/commit/6c0fd408779bc084698499bca861d042cd735d77))
* **perms:** add new permission for viewing recently added media ([#2129](https://github.com/fallenbagel/jellyseerr/issues/2129)) ([a12697b](https://github.com/fallenbagel/jellyseerr/commit/a12697b06143e9a9c5c240c104faabdf2096ffd3))
* plex deep links for iOS devices ([#2680](https://github.com/fallenbagel/jellyseerr/issues/2680)) ([575da30](https://github.com/fallenbagel/jellyseerr/commit/575da306b03eea3561de8d7dbe1b4b69674c7b2b))
* plex watchlist sync integration ([#2885](https://github.com/fallenbagel/jellyseerr/issues/2885)) ([301f2bf](https://github.com/fallenbagel/jellyseerr/commit/301f2bf7ab0c5e7c5aef9d78a58d6449df0f55b8))
* pull down to refresh ([#2908](https://github.com/fallenbagel/jellyseerr/issues/2908)) ([87825a0](https://github.com/fallenbagel/jellyseerr/commit/87825a0e058162e82634503c81deeff1a59634e5))
* restore option to cache and optimize images locally ([#2964](https://github.com/fallenbagel/jellyseerr/issues/2964)) ([507227a](https://github.com/fallenbagel/jellyseerr/commit/507227aa496a60d1f46ce1a34c13bb162edc4bb6))
* season/episode list on series details ([#2967](https://github.com/fallenbagel/jellyseerr/issues/2967)) ([8a2acb7](https://github.com/fallenbagel/jellyseerr/commit/8a2acb7f2bbe91feb3c6ee45c2066e3a036b83f9))
* show alert/prompt when settings changes require restart ([#2401](https://github.com/fallenbagel/jellyseerr/issues/2401)) ([f3e56da](https://github.com/fallenbagel/jellyseerr/commit/f3e56da3b719285095a59d5a0e822087e095b709))
* tooltip foundation ([#2950](https://github.com/fallenbagel/jellyseerr/issues/2950)) ([16545ee](https://github.com/fallenbagel/jellyseerr/commit/16545eec225ce942b55935019185a94e471fb93b))
* **ui:** revalidate requests slider on discover page ([#2818](https://github.com/fallenbagel/jellyseerr/issues/2818)) ([91e0928](https://github.com/fallenbagel/jellyseerr/commit/91e0928aa0e1353431754750095cb64b93348c21))
* user delete modal shows username and requires confirmation ([#2779](https://github.com/fallenbagel/jellyseerr/issues/2779)) ([36d17fe](https://github.com/fallenbagel/jellyseerr/commit/36d17fed6e4e1ca651a4e29f087b2abb53f794cf))
* view other users' watchlists ([#2959](https://github.com/fallenbagel/jellyseerr/issues/2959)) ([0839718](https://github.com/fallenbagel/jellyseerr/commit/0839718806a04ee094445dd7276bba7f49424ab7))
* add discover customization ([#3182](https://github.com/fallenbagel/jellyseerr/issues/3182)) ([cd35748](https://github.com/fallenbagel/jellyseerr/commit/cd3574851a12517cbfadc109e6412a7a9e44c114))
* add keywords to movie/series detail pages ([#3204](https://github.com/fallenbagel/jellyseerr/issues/3204)) ([e084649](https://github.com/fallenbagel/jellyseerr/commit/e084649878a58c296786141d12dd69a69a27ee85))
* add streaming services filter ([#3247](https://github.com/fallenbagel/jellyseerr/issues/3247)) ([1154156](https://github.com/fallenbagel/jellyseerr/commit/1154156459403494e8daf0c89a3ba356aeea1d97))
* discover inline customization ([#3220](https://github.com/fallenbagel/jellyseerr/issues/3220)) ([8bd10b5](https://github.com/fallenbagel/jellyseerr/commit/8bd10b5bf3d1b8069872b616c7c8596caeb4937e))
* discover overhaul (filters!) ([#3232](https://github.com/fallenbagel/jellyseerr/issues/3232)) ([dd00e48](https://github.com/fallenbagel/jellyseerr/commit/dd00e48f59054b44bef6b32a2c169e59f6175051))
* discover slider edit arrow buttons for reordering ([#3259](https://github.com/fallenbagel/jellyseerr/issues/3259)) ([da00d45](https://github.com/fallenbagel/jellyseerr/commit/da00d454e17e8b00d04f6e26f6dd5153ed6ced81))
* **lang:** translations update from Hosted Weblate ([#3030](https://github.com/fallenbagel/jellyseerr/issues/3030)) ([0d8b390](https://github.com/fallenbagel/jellyseerr/commit/0d8b390b678731e76bd1f0f8a0a4952c11e77f4d))
* new mobile menu ([#3251](https://github.com/fallenbagel/jellyseerr/issues/3251)) ([fcbca17](https://github.com/fallenbagel/jellyseerr/commit/fcbca1722f31f32633a57bc5048f46c9da057d87))
* translations update from Hosted Weblate ([#3218](https://github.com/fallenbagel/jellyseerr/issues/3218)) ([5940ff7](https://github.com/fallenbagel/jellyseerr/commit/5940ff7f5f62eed9ac5aa6f02803418aaa09813a))
* **ui:** add episode number to front of episode name in season details ([#3086](https://github.com/fallenbagel/jellyseerr/issues/3086)) ([a672b32](https://github.com/fallenbagel/jellyseerr/commit/a672b324ec391a20f6f3a1daed82a8d276a52c2c))
* **ui:** request card progress bar ([#3123](https://github.com/fallenbagel/jellyseerr/issues/3123)) ([03853a1](https://github.com/fallenbagel/jellyseerr/commit/03853a1b9155c8a2153c8885022a74619af1bc15))
# [1.1.1](https://github.com/fallenbagel/jellyseerr/compare/v1.1.0...v1.1.1) (2022-06-20)
# [1.3.0](https://github.com/fallenbagel/jellyseerr/compare/v1.2.1...v1.3.0) (2023-01-02)
### Bug Fixes
- added deep links to issues and status badges ([#3065](https://github.com/fallenbagel/jellyseerr/issues/3065)) ([bfe56c3](https://github.com/fallenbagel/jellyseerr/commit/bfe56c347073001795b1c3e917eb7a5afcc4462c))
- **api:** handle auth for accounts where the plex id may have been set to null ([#3125](https://github.com/fallenbagel/jellyseerr/issues/3125)) ([15e2469](https://github.com/fallenbagel/jellyseerr/commit/15e246929bdbc2b7b5bdab7a84bd7882b79d5cb1))
- **api:** ignore Music,Books,Photos,MusicVideo libraries ([d9ca3c6](https://github.com/fallenbagel/jellyseerr/commit/d9ca3c6e52c118698ca71021217f6ca409e71974))
- count combined episodes ([64339e5](https://github.com/fallenbagel/jellyseerr/commit/64339e5f0374f8490e685e5c086e088bb7fd737e))
- improved PTR scrolling performance ([#3095](https://github.com/fallenbagel/jellyseerr/issues/3095)) ([07ec3ef](https://github.com/fallenbagel/jellyseerr/commit/07ec3efbcaf669de7ccde4421c1112bfd23675d6))
- **locale:** fix the duplicated wording in the Clear Media Warning message ([7e20c7c](https://github.com/fallenbagel/jellyseerr/commit/7e20c7cb78a44c32ab8a5f21203e285f23f402ab))
- **ui:** adds mediaServerName to statusBadge and manageSlideOver ([d0cdce9](https://github.com/fallenbagel/jellyseerr/commit/d0cdce9e90fba642d2bf934a4266e1421424bc73)), closes [#254](https://github.com/fallenbagel/jellyseerr/issues/254)
- update API docs to allow 'all' seasons value ([#3073](https://github.com/fallenbagel/jellyseerr/issues/3073)) ([1dfa943](https://github.com/fallenbagel/jellyseerr/commit/1dfa9431a95e7e2a1843746c2473d8a06f03e184))
### Features
- **api:** adds support for Mixed Libraries ([ba82ece](https://github.com/fallenbagel/jellyseerr/commit/ba82ecec5c994e79d7c9b658372041522b58a120)), closes [#95](https://github.com/fallenbagel/jellyseerr/issues/95)
- custom image proxy ([#3056](https://github.com/fallenbagel/jellyseerr/issues/3056)) ([500cd1f](https://github.com/fallenbagel/jellyseerr/commit/500cd1f872942923d2b9c3b835e6329e335d4a3f))
- **lang:** add Croatian display language ([#3041](https://github.com/fallenbagel/jellyseerr/issues/3041)) ([64aab6d](https://github.com/fallenbagel/jellyseerr/commit/64aab6dd8240e191026512733b34cc046b6e508a))
## [1.29.1](https://github.com/sct/overseerr/compare/v1.29.0...v1.29.1) (2022-04-06)
### Bug Fixes

View File

@@ -13,37 +13,105 @@ _The original Overseerr team have been busy and Jellyfin/Emby support aren't on
## Current Features
- Jellyfin Support
- Emby Support
(Upcoming Features include: Multiple Server Instances, Music Support, Ability to change email address and much more!)
Along with all the existing Overseerr features:
- Full Plex integration. Authenticate and manage user access with Plex!
- Full Jellyfin/Emby/Plex integration. Authenticate and manage user access with Jellyfin/Emby/Plex!
- Supports Movies, Shows, Mixed Libraries!
- Ability to change email addresses for smtp purposes
- Ability to import all jellyfin/emby users
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
- Plex library scan, to keep track of the titles which are already available.
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
- Granular permission system.
- Support for various notification agents.
- Mobile-friendly design, for when you need to approve requests on the go!
(Upcoming Features include: Multiple Server Instances, Music Support, and much more!)
With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested.
## Getting Started
#### Pre-requisite (Important)
_*On Jellyfin/Emby, ensure the `settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_
### Launching Jellyseerr using Docker
Check out our dockerhub for instructions on how to install and run Jellyseerr:
https://hub.docker.com/r/fallenbagel/jellyseerr
### Launching Jellyseerr manually:
#### Windows
Pre-requisites:
- Nodejs (atleast LTS version)
- Yarn
- Download the source code from the github (Either develop branch or main for stable)
```bash
npm i -g win-node-env
yarn install
yarn run build
yarn start
```
#### Linux
Pre-requisites:
- Nodejs (atleast LTS version)
- Yarn
- Git
```bash
git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr
git checkout main #if you want to run stable instead of develop
yarn install
yarn run build
yarn start
```
_Systemd-service:_
- assuming jellyseerr was cloned to `/opt/`
and the environmentfile is located at `/etc/jellyseerr`
service:
```
[Unit]
Description=Jellyseerr Service
Wants=network-online.target
After=network-online.target
[Service]
EnvironmentFile=/etc/jellyseerr/jellyseerr.conf
Environment=NODE_ENV=production
Type=exec
Restart=on-failure
WorkingDirectory=/opt/jellyseerr
ExecStart=/root/.nvm/versions/node/v18.7.0/bin/node dist/index.js
[Install]
WantedBy=multi-user.target
```
Environmentfile:
```
# Jellyseerr's default port is 5055, if you want to use both, change this.
# specify on which port to listen
PORT=5055
# specify on which interface to listen, by default jellyseerr listens on all interfaces
#HOST=127.0.0.1
# Uncomment if your media server is emby instead of jellyfin.
# JELLYFIN_TYPE=emby
```
### Packages:
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'cypress';
export default defineConfig({
projectId: 'onnqy3',
projectId: 'xkm1b4',
e2e: {
baseUrl: 'http://localhost:5055',
experimentalSessionAndOrigin: true,

View File

@@ -36,7 +36,9 @@ describe('Discover', () => {
});
it('loads upcoming movies', () => {
cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies');
cy.intercept('/api/v1/discover/movies?page=1&primaryReleaseDateGte*').as(
'getUpcomingMovies'
);
cy.visit('/');
cy.wait('@getUpcomingMovies');
clickFirstTitleCardInSlider('Upcoming Movies');
@@ -50,7 +52,9 @@ describe('Discover', () => {
});
it('loads upcoming series', () => {
cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries');
cy.intercept('/api/v1/discover/tv?page=1&firstAirDateGte=*').as(
'getUpcomingSeries'
);
cy.visit('/');
cy.wait('@getUpcomingSeries');
clickFirstTitleCardInSlider('Upcoming Series');

View File

@@ -0,0 +1,163 @@
describe('Discover Customization', () => {
beforeEach(() => {
cy.loginAsAdmin();
cy.intercept('/api/v1/settings/discover').as('getDiscoverSliders');
});
it('show the discover customization settings', () => {
cy.visit('/');
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=create-slider-header')
.should('contain', 'Create New Slider')
.scrollIntoView();
// There should be some built in options
cy.get('[data-testid=discover-slider-edit-mode]').should(
'contain',
'Recently Added'
);
cy.get('[data-testid=discover-slider-edit-mode]').should(
'contain',
'Recent Requests'
);
});
it('can drag to re-order elements and save to persist the changes', () => {
let dataTransfer = new DataTransfer();
cy.visit('/');
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.trigger('dragstart', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('drop', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('dragend', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recently Added');
cy.get('[data-testid=discover-customize-submit').click();
cy.wait('@getDiscoverSliders');
cy.reload();
cy.get('[data-testid=discover-start-editing]').click();
dataTransfer = new DataTransfer();
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recently Added');
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.trigger('dragstart', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('drop', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('dragend', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recent Requests');
cy.get('[data-testid=discover-customize-submit').click();
cy.wait('@getDiscoverSliders');
});
it('can create a new discover option and remove it', () => {
cy.visit('/');
cy.intercept('/api/v1/settings/discover/*').as('discoverSlider');
cy.intercept('/api/v1/search/keyword*').as('searchKeyword');
cy.get('[data-testid=discover-start-editing]').click();
const sliderTitle = 'Custom Keyword Slider';
cy.get('#sliderType').select('TMDB Movie Keyword');
cy.get('#title').type(sliderTitle);
// First confirm that an invalid keyword doesn't allow us to submit anything
cy.get('#data').type('invalidkeyword{enter}', { delay: 100 });
cy.wait('@searchKeyword');
cy.get('[data-testid=create-discover-option-form]')
.find('button')
.should('be.disabled');
cy.get('#data').clear();
cy.get('#data').type('time travel{enter}', { delay: 100 });
// Confirming we have some results
cy.contains('.slider-header', sliderTitle)
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]');
cy.get('[data-testid=create-discover-option-form]').submit();
cy.wait('@discoverSlider');
cy.wait('@getDiscoverSliders');
cy.wait(1000);
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('contain', sliderTitle);
// Make sure its still there even if we reload
cy.reload();
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('contain', sliderTitle);
// Verify it's not rendering on our discover page (its still disabled!)
cy.visit('/');
cy.get('.slider-header').should('not.contain', sliderTitle);
cy.get('[data-testid=discover-start-editing]').click();
// Enable it, and check again
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.find('[role="checkbox"]')
.click();
cy.get('[data-testid=discover-customize-submit').click();
cy.wait('@getDiscoverSliders');
cy.visit('/');
cy.contains('.slider-header', sliderTitle)
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]');
cy.get('[data-testid=discover-start-editing]').click();
// let's delete it and confirm its deleted.
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.find('[data-testid=discover-slider-remove-button]')
.click();
cy.wait('@discoverSlider');
cy.wait('@getDiscoverSliders');
cy.wait(1000);
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('not.contain', sliderTitle);
});
});

View File

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

View File

@@ -11,4 +11,4 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
```
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.

View File

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

View File

@@ -40,6 +40,14 @@ If you enable this setting and find yourself unable to access Overseerr, you can
This setting is **disabled** by default.
### Enable Image Caching
When enabled, Overseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space.
Images are saved in the `config/cache/images` and stale images are cleared out every 24 hours.
You should enable this if you are having issues with loading images directly from TMDB in your browser.
### Display Language
Set the default display language for Overseerr. Users can override this setting in their user settings.

View File

@@ -26,6 +26,8 @@ tags:
description: Endpoints related to retrieving movies and their details.
- name: tv
description: Endpoints related to retrieving TV series and their details.
- name: other
description: Endpoints related to other TMDB data
- name: person
description: Endpoints related to retrieving person details.
- name: media
@@ -648,6 +650,17 @@ components:
name:
type: string
example: Adventure
Company:
type: object
properties:
id:
type: number
example: 1
logo_path:
type: string
nullable: true
name:
type: string
ProductionCompany:
type: object
properties:
@@ -1087,6 +1100,8 @@ components:
nullable: true
status:
type: number
example: 0
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
requests:
type: array
readOnly: true
@@ -1828,6 +1843,40 @@ components:
message:
type: string
example: A comment
DiscoverSlider:
type: object
properties:
id:
type: number
example: 1
type:
type: number
example: 1
title:
type: string
nullable: true
isBuiltIn:
type: boolean
enabled:
type: boolean
data:
type: string
example: '1234'
nullable: true
required:
- type
- enabled
- title
- data
WatchProviderRegion:
type: object
properties:
iso_3166_1:
type: string
english_name:
type: string
native_name:
type: string
securitySchemes:
cookieAuth:
type: apiKey
@@ -2667,29 +2716,44 @@ paths:
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: string
example: cache-id
name:
type: string
example: cache name
stats:
type: object
properties:
imageCache:
type: object
properties:
tmdb:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches:
type: array
items:
type: object
properties:
hits:
type: number
misses:
type: number
keys:
type: number
ksize:
type: number
vsize:
type: number
id:
type: string
example: cache-id
name:
type: string
example: cache name
stats:
type: object
properties:
hits:
type: number
misses:
type: number
keys:
type: number
ksize:
type: number
vsize:
type: number
/settings/cache/{cacheId}/flush:
post:
summary: Flush a specific cache
@@ -3219,6 +3283,133 @@ paths:
responses:
'204':
description: Test notification attempted
/settings/discover:
get:
summary: Get all discover sliders
description: Returns all discovery sliders. Built-in and custom made.
tags:
- settings
responses:
'200':
description: Returned all discovery sliders
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DiscoverSlider'
post:
summary: Batch update all sliders.
description: |
Batch update all sliders at once. Should also be used for creation. Will only update sliders provided
and will not delete any sliders not present in the request. If a slider is missing a required field,
it will be ignored. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DiscoverSlider'
responses:
'200':
description: Returned all newly updated discovery sliders
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DiscoverSlider'
/settings/discover/{sliderId}:
put:
summary: Update a single slider
description: |
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
example: 'Slider Title'
type:
type: number
example: 1
data:
type: string
example: '1'
responses:
'200':
description: Returns newly added discovery slider
content:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
delete:
summary: Delete slider by ID
description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission.
tags:
- settings
parameters:
- in: path
name: sliderId
required: true
schema:
type: number
responses:
'200':
description: Slider successfully deleted
content:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
/settings/discover/add:
post:
summary: Add a new slider
description: |
Add a single slider and return the newly created slider. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
example: 'New Slider'
type:
type: number
example: 1
data:
type: string
example: '1'
responses:
'200':
description: Returns newly added discovery slider
content:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
/settings/discover/reset:
get:
summary: Reset all discover sliders
description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission.
tags:
- settings
responses:
'204':
description: All sliders reset to defaults
/settings/about:
get:
summary: Get server stats
@@ -4100,6 +4291,86 @@ paths:
- $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult'
- $ref: '#/components/schemas/PersonResult'
/search/keyword:
get:
summary: Search for keywords
description: Returns a list of TMDB keywords matching the search query
tags:
- search
parameters:
- in: query
name: query
required: true
schema:
type: string
example: 'christmas'
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: Results
content:
application/json:
schema:
type: object
properties:
page:
type: number
example: 1
totalPages:
type: number
example: 20
totalResults:
type: number
example: 200
results:
type: array
items:
$ref: '#/components/schemas/Keyword'
/search/company:
get:
summary: Search for companies
description: Returns a list of TMDB companies matching the search query. (Will not return origin country)
tags:
- search
parameters:
- in: query
name: query
required: true
schema:
type: string
example: 'Disney'
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: Results
content:
application/json:
schema:
type: object
properties:
page:
type: number
example: 1
totalPages:
type: number
example: 20
totalResults:
type: number
example: 200
results:
type: array
items:
$ref: '#/components/schemas/Company'
/discover/movies:
get:
summary: Discover movies
@@ -4121,13 +4392,63 @@ paths:
- in: query
name: genre
schema:
type: number
type: string
example: 18
- in: query
name: studio
schema:
type: number
example: 1
- in: query
name: keywords
schema:
type: string
example: 1,2
- in: query
name: sortBy
schema:
type: string
example: popularity.desc
- in: query
name: primaryReleaseDateGte
schema:
type: string
example: 2022-01-01
- in: query
name: primaryReleaseDateLte
schema:
type: string
example: 2023-01-01
- in: query
name: withRuntimeGte
schema:
type: number
example: 60
- in: query
name: withRuntimeLte
schema:
type: number
example: 120
- in: query
name: voteAverageGte
schema:
type: number
example: 7
- in: query
name: voteAverageLte
schema:
type: number
example: 10
- in: query
name: watchRegion
schema:
type: string
example: US
- in: query
name: watchProviders
schema:
type: string
example: 8|9
responses:
'200':
description: Results
@@ -4350,13 +4671,63 @@ paths:
- in: query
name: genre
schema:
type: number
type: string
example: 18
- in: query
name: network
schema:
type: number
example: 1
- in: query
name: keywords
schema:
type: string
example: 1,2
- in: query
name: sortBy
schema:
type: string
example: popularity.desc
- in: query
name: firstAirDateGte
schema:
type: string
example: 2022-01-01
- in: query
name: firstAirDateLte
schema:
type: string
example: 2023-01-01
- in: query
name: withRuntimeGte
schema:
type: number
example: 60
- in: query
name: withRuntimeLte
schema:
type: number
example: 120
- in: query
name: voteAverageGte
schema:
type: number
example: 7
- in: query
name: voteAverageLte
schema:
type: number
example: 10
- in: query
name: watchRegion
schema:
type: string
example: US
- in: query
name: watchProviders
schema:
type: string
example: 8|9
responses:
'200':
description: Results
@@ -4838,9 +5209,13 @@ paths:
type: number
example: 123
seasons:
type: array
items:
type: number
oneOf:
- type: array
items:
type: number
minimum: 1
- type: string
enum: [all]
is4k:
type: boolean
example: false
@@ -4919,7 +5294,7 @@ paths:
$ref: '#/components/schemas/MediaRequest'
put:
summary: Update MediaRequest
description: Updates a specific media request and returns the request in a JSON object.. Requires the `MANAGE_REQUESTS` permission.
description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission.
tags:
- request
parameters:
@@ -4930,6 +5305,37 @@ paths:
example: '1'
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
mediaType:
type: string
enum: [movie, tv]
seasons:
type: array
items:
type: number
minimum: 1
is4k:
type: boolean
example: false
serverId:
type: number
profileId:
type: number
rootFolder:
type: string
languageProfileId:
type: number
userId:
type: number
nullable: true
required:
- mediaType
responses:
'200':
description: Succesfully updated request
@@ -5000,7 +5406,7 @@ paths:
required: true
schema:
type: string
enum: [pending, approve, decline, available]
enum: [approve, decline]
responses:
'200':
description: Request status changed
@@ -6120,6 +6526,89 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Issue'
/keyword/{keywordId}:
get:
summary: Get keyword
description: |
Returns a single keyword in JSON format.
tags:
- other
parameters:
- in: path
name: keywordId
required: true
schema:
type: number
example: 1
responses:
'200':
description: Keyword returned
content:
application/json:
schema:
$ref: '#/components/schemas/Keyword'
/watchproviders/regions:
get:
summary: Get watch provider regions
description: |
Returns a list of all available watch provider regions.
tags:
- other
responses:
'200':
description: Watch provider regions returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderRegion'
/watchproviders/movies:
get:
summary: Get watch provider movies
description: |
Returns a list of all available watch providers for movies.
tags:
- other
parameters:
- in: query
name: watchRegion
required: true
schema:
type: string
example: US
responses:
'200':
description: Watch providers for movies returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderDetails'
/watchproviders/tv:
get:
summary: Get watch provider series
description: |
Returns a list of all available watch providers for series.
tags:
- other
parameters:
- in: query
name: watchRegion
required: true
schema:
type: string
example: US
responses:
'200':
description: Watch providers for series returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderDetails'
security:
- cookieAuth: []
- apiKey: []

View File

@@ -1,6 +1,6 @@
{
"name": "jellyseerr",
"version": "1.2.0",
"version": "1.4.0",
"private": true,
"scripts": {
"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",
@@ -29,145 +29,149 @@
},
"license": "MIT",
"dependencies": {
"@formatjs/intl-displaynames": "6.0.3",
"@formatjs/intl-locale": "3.0.3",
"@formatjs/intl-pluralrules": "5.0.3",
"@formatjs/intl-displaynames": "6.2.3",
"@formatjs/intl-locale": "3.0.11",
"@formatjs/intl-pluralrules": "5.1.8",
"@formatjs/intl-utils": "3.8.4",
"@headlessui/react": "0.0.0-insiders.b301f04",
"@heroicons/react": "1.0.6",
"@headlessui/react": "1.7.7",
"@heroicons/react": "2.0.13",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.3.1",
"@tanem/react-nprogress": "5.0.11",
"ace-builds": "1.9.6",
"axios": "0.27.2",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.22",
"ace-builds": "1.14.0",
"axios": "1.2.2",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.0.1",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
"connect-typeorm": "1.1.4",
"cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.2",
"copy-to-clipboard": "3.3.3",
"country-flag-icons": "1.5.5",
"cronstrue": "2.11.0",
"cronstrue": "2.21.0",
"csurf": "1.11.0",
"date-fns": "2.29.1",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
"email-templates": "9.0.0",
"email-validator": "2.0.4",
"express": "4.18.1",
"express": "4.18.2",
"express-openapi-validator": "4.13.8",
"express-rate-limit": "6.5.1",
"express-rate-limit": "6.7.0",
"express-session": "1.17.3",
"formik": "2.2.9",
"gravatar-url": "3.1.0",
"intl": "1.2.5",
"lodash": "4.17.21",
"next": "12.2.5",
"next": "12.3.4",
"node-cache": "5.1.2",
"node-gyp": "9.1.0",
"node-gyp": "9.3.1",
"node-schedule": "2.1.0",
"nodemailer": "6.7.8",
"openpgp": "5.4.0",
"nodemailer": "6.8.0",
"openpgp": "5.5.0",
"plex-api": "5.3.2",
"pug": "3.0.2",
"pulltorefreshjs": "0.1.22",
"react": "18.2.0",
"react-ace": "10.1.0",
"react-animate-height": "2.1.2",
"react-aria": "3.22.0",
"react-dom": "18.2.0",
"react-intersection-observer": "9.4.0",
"react-intl": "6.0.5",
"react-markdown": "8.0.3",
"react-intersection-observer": "9.4.1",
"react-intl": "6.2.5",
"react-markdown": "8.0.4",
"react-popper-tooltip": "4.4.2",
"react-select": "5.4.0",
"react-spring": "9.5.2",
"react-select": "5.7.0",
"react-spring": "9.6.1",
"react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1",
"react-truncate-markup": "5.1.2",
"react-use-clipboard": "1.0.8",
"react-use-clipboard": "1.0.9",
"reflect-metadata": "0.1.13",
"secure-random-password": "0.2.3",
"semver": "7.3.7",
"sqlite3": "5.0.11",
"swagger-ui-express": "4.5.0",
"swr": "1.3.0",
"typeorm": "0.3.7",
"semver": "7.3.8",
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.0",
"swr": "2.0.0",
"typeorm": "0.3.11",
"web-push": "3.5.0",
"winston": "3.8.1",
"winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1",
"xml2js": "0.4.23",
"yamljs": "0.3.0",
"yup": "0.32.11"
"yup": "0.32.11",
"zod": "3.20.2"
},
"devDependencies": {
"@babel/cli": "7.18.10",
"@commitlint/cli": "17.0.3",
"@commitlint/config-conventional": "17.0.3",
"@semantic-release/changelog": "6.0.1",
"@babel/cli": "7.20.7",
"@commitlint/cli": "17.4.0",
"@commitlint/config-conventional": "17.4.0",
"@semantic-release/changelog": "6.0.2",
"@semantic-release/commit-analyzer": "9.0.2",
"@semantic-release/exec": "6.0.3",
"@semantic-release/git": "10.0.1",
"@tailwindcss/aspect-ratio": "0.4.0",
"@tailwindcss/forms": "0.5.2",
"@tailwindcss/typography": "0.5.4",
"@tailwindcss/aspect-ratio": "0.4.2",
"@tailwindcss/forms": "0.5.3",
"@tailwindcss/typography": "0.5.8",
"@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.3",
"@types/country-flag-icons": "1.2.0",
"@types/csurf": "1.11.2",
"@types/email-templates": "8.0.4",
"@types/express": "4.17.13",
"@types/express-session": "1.17.4",
"@types/lodash": "4.14.183",
"@types/express": "4.17.15",
"@types/express-session": "1.17.5",
"@types/lodash": "4.14.191",
"@types/node": "17.0.36",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.5",
"@types/nodemailer": "6.4.7",
"@types/pulltorefreshjs": "0.1.5",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.12",
"@types/semver": "7.3.13",
"@types/swagger-ui-express": "4.1.3",
"@types/web-push": "3.3.2",
"@types/xml2js": "0.4.11",
"@types/yamljs": "0.2.31",
"@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "5.33.1",
"@typescript-eslint/parser": "5.33.1",
"autoprefixer": "10.4.8",
"@typescript-eslint/eslint-plugin": "5.48.0",
"@typescript-eslint/parser": "5.48.0",
"autoprefixer": "10.4.13",
"babel-plugin-react-intl": "8.2.25",
"babel-plugin-react-intl-auto": "3.3.0",
"commitizen": "4.2.5",
"commitizen": "4.2.6",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
"cypress": "10.6.0",
"cypress": "12.3.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.22.0",
"eslint-config-next": "12.2.5",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-formatjs": "4.1.0",
"eslint": "8.31.0",
"eslint-config-next": "12.3.4",
"eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.3.9",
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-no-relative-import-paths": "1.4.0",
"eslint-plugin-no-relative-import-paths": "1.5.2",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.30.1",
"eslint-plugin-react": "7.31.11",
"eslint-plugin-react-hooks": "4.6.0",
"extract-react-intl-messages": "4.1.1",
"husky": "8.0.1",
"lint-staged": "12.4.3",
"nodemon": "2.0.19",
"postcss": "8.4.16",
"prettier": "2.7.1",
"prettier-plugin-organize-imports": "3.1.0",
"prettier-plugin-tailwindcss": "0.1.13",
"semantic-release": "19.0.3",
"husky": "8.0.3",
"lint-staged": "13.1.0",
"nodemon": "2.0.20",
"postcss": "8.4.20",
"prettier": "2.8.1",
"prettier-plugin-organize-imports": "3.2.1",
"prettier-plugin-tailwindcss": "0.2.1",
"semantic-release": "19.0.5",
"semantic-release-docker-buildx": "1.0.1",
"tailwindcss": "3.1.8",
"tailwindcss": "3.2.4",
"ts-node": "10.9.1",
"tsc-alias": "1.7.0",
"tsconfig-paths": "4.1.0",
"typescript": "4.7.4"
"tsc-alias": "1.8.2",
"tsconfig-paths": "4.1.2",
"typescript": "4.9.4"
},
"resolutions": {
"sqlite3/node-gyp": "8.4.1",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6"
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10"
},
"config": {
"commitizen": {
@@ -225,7 +229,7 @@
{
"path": "semantic-release-docker-buildx",
"buildArgs": {
"COMMIT_TAG": "$GITHUB_SHA"
"COMMIT_TAG": "$GIT_SHA"
},
"imageNames": [
"fallenbagel/jellyseerr"

View File

@@ -1,3 +0,0 @@
[ZoneTransfer]
LastWriterPackageFamilyName=Microsoft.ScreenSketch_8wekyb3d8bbwe
ZoneId=3

View File

@@ -69,6 +69,30 @@ class ExternalAPI {
return response.data;
}
protected async post<T>(
endpoint: string,
data: Record<string, unknown>,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params,
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.post<T>(endpoint, data, config);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
protected async getRolling<T>(
endpoint: string,
config?: AxiosRequestConfig,

View File

@@ -38,6 +38,7 @@ export interface JellyfinLibraryItem {
SeasonId?: string;
SeasonName?: string;
IndexNumber?: number;
IndexNumberEnd?: number;
ParentIndexNumber?: number;
MediaType: string;
}
@@ -178,8 +179,10 @@ class JellyfinAPI {
(Item: any) => {
return (
Item.Type === 'CollectionFolder' &&
(Item.CollectionType === 'tvshows' ||
Item.CollectionType === 'movies')
Item.CollectionType !== 'music' &&
Item.CollectionType !== 'books' &&
Item.CollectionType !== 'musicvideos' &&
Item.CollectionType !== 'homevideos'
);
}
).map((Item: any) => {
@@ -204,7 +207,7 @@ class JellyfinAPI {
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try {
const contents = await this.axios.get<any>(
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie&Recursive=true&StartIndex=0&ParentId=${id}`
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}`
);
return contents.data.Items.filter(

View File

@@ -1,28 +1,40 @@
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import ExternalAPI from './externalapi';
interface RTSearchResult {
meterClass: 'certified_fresh' | 'fresh' | 'rotten';
meterScore: number;
url: string;
interface RTAlgoliaSearchResponse {
results: {
hits: RTAlgoliaHit[];
index: 'content_rt' | 'people_rt';
}[];
}
interface RTTvSearchResult extends RTSearchResult {
interface RTAlgoliaHit {
emsId: string;
emsVersionId: string;
tmsId: string;
type: string;
title: string;
startYear: number;
endYear: number;
}
interface RTMovieSearchResult extends RTSearchResult {
name: string;
url: string;
year: number;
}
interface RTMultiSearchResponse {
tvCount: number;
tvSeries: RTTvSearchResult[];
movieCount: number;
movies: RTMovieSearchResult[];
titles: string[];
description: string;
releaseYear: string;
rating: string;
genres: string[];
updateDate: string;
isEmsSearchable: boolean;
rtId: number;
vanity: string;
aka: string[];
posterImageUrl: string;
rottenTomatoes: {
audienceScore: number;
criticsIconUrl: string;
wantToSeeCount: number;
audienceIconUrl: string;
scoreSentiment: string;
certifiedFresh: boolean;
criticsScore: number;
};
}
export interface RTRating {
@@ -47,13 +59,20 @@ export interface RTRating {
*/
class RottenTomatoes extends ExternalAPI {
constructor() {
const settings = getSettings();
super(
'https://www.rottentomatoes.com/api/private',
{},
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
{
'x-algolia-agent':
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
'x-algolia-application-id': '79FRDP12PN',
},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-algolia-usertoken': settings.clientId,
},
nodeCache: cacheManager.getCache('rt').data,
}
@@ -61,14 +80,11 @@ class RottenTomatoes extends ExternalAPI {
}
/**
* Search the 1.0 api for the movie title
* Search the RT algolia api for the movie title
*
* We compare the release date to make sure its the correct
* match. But it's not guaranteed to have results.
*
* We use the 1.0 API here because the 2.0 search api does
* not return audience ratings.
*
* @param name Movie name
* @param year Release Year
*/
@@ -77,30 +93,45 @@ class RottenTomatoes extends ExternalAPI {
year: number
): Promise<RTRating | null> {
try {
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
params: { q: name, limit: 10 },
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
const contentResults = data.results.find((r) => r.index === 'content_rt');
if (!contentResults) {
return null;
}
// First, attempt to match exact name and year
let movie = data.movies.find(
(movie) => movie.year === year && movie.name === name
let movie = contentResults.hits.find(
(movie) => movie.releaseYear === year.toString() && movie.title === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = data.movies.find(
(movie) => movie.year === year && movie.name.includes(name)
movie = contentResults.hits.find(
(movie) =>
movie.releaseYear === year.toString() && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = data.movies.find((movie) => movie.year === year);
movie = contentResults.hits.find(
(movie) => movie.releaseYear === year.toString()
);
}
// One last try, try exact name match only
if (!movie) {
movie = data.movies.find((movie) => movie.name === name);
movie = contentResults.hits.find((movie) => movie.title === name);
}
if (!movie) {
@@ -108,16 +139,15 @@ class RottenTomatoes extends ExternalAPI {
}
return {
title: movie.name,
url: `https://www.rottentomatoes.com${movie.url}`,
criticsRating:
movie.meterClass === 'certified_fresh'
? 'Certified Fresh'
: movie.meterClass === 'fresh'
? 'Fresh'
: 'Rotten',
criticsScore: movie.meterScore,
year: movie.year,
title: movie.title,
url: `https://www.rottentomatoes.com/m/${movie.vanity}`,
criticsRating: movie.rottenTomatoes.certifiedFresh
? 'Certified Fresh'
: movie.rottenTomatoes.criticsScore >= 60
? 'Fresh'
: 'Rotten',
criticsScore: movie.rottenTomatoes.criticsScore,
year: Number(movie.releaseYear),
};
} catch (e) {
throw new Error(
@@ -131,14 +161,28 @@ class RottenTomatoes extends ExternalAPI {
year?: number
): Promise<RTRating | null> {
try {
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
params: { q: name, limit: 10 },
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
const contentResults = data.results.find((r) => r.index === 'content_rt');
if (!contentResults) {
return null;
}
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
if (year) {
tvshow = data.tvSeries.find((series) => series.startYear === year);
tvshow = contentResults.hits.find(
(series) => series.releaseYear === year.toString()
);
}
if (!tvshow) {
@@ -147,10 +191,11 @@ class RottenTomatoes extends ExternalAPI {
return {
title: tvshow.title,
url: `https://www.rottentomatoes.com${tvshow.url}`,
criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten',
criticsScore: tvshow.meterScore,
year: tvshow.startYear,
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,
criticsRating:
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
criticsScore: tvshow.rottenTomatoes.criticsScore,
year: Number(tvshow.releaseYear),
};
} catch (e) {
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);

View File

@@ -158,7 +158,12 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
`/queue`
`/queue`,
{
params: {
includeEpisode: true,
},
}
);
return response.data.records;

View File

@@ -13,6 +13,21 @@ interface SonarrSeason {
percentOfEpisodes: number;
};
}
interface EpisodeResult {
seriesId: number;
episodeFileId: number;
seasonNumber: number;
episodeNumber: number;
title: string;
airDate: string;
airDateUtc: string;
overview: string;
hasFile: boolean;
monitored: boolean;
absoluteEpisodeNumber: number;
unverifiedSceneNumbering: boolean;
id: number;
}
export interface SonarrSeries {
title: string;
@@ -82,7 +97,11 @@ export interface LanguageProfile {
name: string;
}
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
class SonarrAPI extends ServarrBase<{
seriesId: number;
episodeId: number;
episode: EpisodeResult;
}> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
}

View File

@@ -3,9 +3,12 @@ import cacheManager from '@server/lib/cache';
import { sortBy } from 'lodash';
import type {
TmdbCollection,
TmdbCompanySearchResponse,
TmdbExternalIdResponse,
TmdbGenre,
TmdbGenresResult,
TmdbKeyword,
TmdbKeywordSearchResponse,
TmdbLanguage,
TmdbMovieDetails,
TmdbNetwork,
@@ -19,6 +22,8 @@ import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
TmdbUpcomingMoviesResponse,
TmdbWatchProviderDetails,
TmdbWatchProviderRegion,
} from './interfaces';
interface SearchOptions {
@@ -32,30 +37,41 @@ interface SingleSearchOptions extends SearchOptions {
year?: number;
}
export type SortOptions =
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
language?: string;
primaryReleaseDateGte?: string;
primaryReleaseDateLte?: string;
withRuntimeGte?: string;
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
originalLanguage?: string;
genre?: number;
studio?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc';
genre?: string;
studio?: string;
keywords?: string;
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
}
interface DiscoverTvOptions {
@@ -63,19 +79,18 @@ interface DiscoverTvOptions {
language?: string;
firstAirDateGte?: string;
firstAirDateLte?: string;
withRuntimeGte?: string;
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
includeEmptyReleaseDate?: boolean;
originalLanguage?: string;
genre?: number;
genre?: string;
network?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
keywords?: string;
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
}
class TheMovieDb extends ExternalAPI {
@@ -237,7 +252,7 @@ class TheMovieDb extends ExternalAPI {
params: {
language,
append_to_response:
'credits,external_ids,videos,release_dates,watch/providers',
'credits,external_ids,videos,keywords,release_dates,watch/providers',
},
},
43200
@@ -440,8 +455,25 @@ class TheMovieDb extends ExternalAPI {
originalLanguage,
genre,
studio,
keywords,
withRuntimeGte,
withRuntimeLte,
voteAverageGte,
voteAverageLte,
watchProviders,
watchRegion,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const defaultFutureDate = new Date(
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
)
.toISOString()
.split('T')[0];
const defaultPastDate = new Date('1900-01-01')
.toISOString()
.split('T')[0];
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: {
sort_by: sortBy,
@@ -449,11 +481,31 @@ class TheMovieDb extends ExternalAPI {
include_adult: includeAdult,
language,
region: this.region,
with_original_language: originalLanguage ?? this.originalLanguage,
'primary_release_date.gte': primaryReleaseDateGte,
'primary_release_date.lte': primaryReleaseDateLte,
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'primary_release_date.gte':
!primaryReleaseDateGte && primaryReleaseDateLte
? defaultPastDate
: primaryReleaseDateGte,
'primary_release_date.lte':
!primaryReleaseDateLte && primaryReleaseDateGte
? defaultFutureDate
: primaryReleaseDateLte,
with_genres: genre,
with_companies: studio,
with_keywords: keywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
watch_region: watchRegion,
with_watch_providers: watchProviders,
},
});
@@ -473,20 +525,57 @@ class TheMovieDb extends ExternalAPI {
originalLanguage,
genre,
network,
keywords,
withRuntimeGte,
withRuntimeLte,
voteAverageGte,
voteAverageLte,
watchProviders,
watchRegion,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const defaultFutureDate = new Date(
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
)
.toISOString()
.split('T')[0];
const defaultPastDate = new Date('1900-01-01')
.toISOString()
.split('T')[0];
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: {
sort_by: sortBy,
page,
language,
region: this.region,
'first_air_date.gte': firstAirDateGte,
'first_air_date.lte': firstAirDateLte,
with_original_language: originalLanguage ?? this.originalLanguage,
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'first_air_date.gte':
!firstAirDateGte && firstAirDateLte
? defaultPastDate
: firstAirDateGte,
'first_air_date.lte':
!firstAirDateLte && firstAirDateGte
? defaultFutureDate
: firstAirDateLte,
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
include_null_first_air_dates: includeEmptyReleaseDate,
with_genres: genre,
with_networks: network,
with_keywords: keywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
with_watch_providers: watchProviders,
watch_region: watchRegion,
},
});
@@ -874,6 +963,152 @@ class TheMovieDb extends ExternalAPI {
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
}
}
public async getKeywordDetails({
keywordId,
}: {
keywordId: number;
}): Promise<TmdbKeyword> {
try {
const data = await this.get<TmdbKeyword>(
`/keyword/${keywordId}`,
undefined,
604800 // 7 days
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
}
}
public async searchKeyword({
query,
page = 1,
}: {
query: string;
page?: number;
}): Promise<TmdbKeywordSearchResponse> {
try {
const data = await this.get<TmdbKeywordSearchResponse>(
'/search/keyword',
{
params: {
query,
page,
},
},
86400 // 24 hours
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to search keyword: ${e.message}`);
}
}
public async searchCompany({
query,
page = 1,
}: {
query: string;
page?: number;
}): Promise<TmdbCompanySearchResponse> {
try {
const data = await this.get<TmdbCompanySearchResponse>(
'/search/company',
{
params: {
query,
page,
},
},
86400 // 24 hours
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to search companies: ${e.message}`);
}
}
public async getAvailableWatchProviderRegions({
language,
}: {
language?: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions',
{
params: {
language: language ?? this.originalLanguage,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch available watch regions: ${e.message}`
);
}
}
public async getMovieWatchProviders({
language,
watchRegion,
}: {
language?: string;
watchRegion: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie',
{
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch movie watch providers: ${e.message}`
);
}
}
public async getTvWatchProviders({
language,
watchRegion,
}: {
language?: string;
watchRegion: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv',
{
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch TV watch providers: ${e.message}`
);
}
}
}
export default TheMovieDb;

View File

@@ -171,6 +171,9 @@ export interface TmdbMovieDetails {
id: number;
results?: { [iso_3166_1: string]: TmdbWatchProviders };
};
keywords: {
keywords: TmdbKeyword[];
};
}
export interface TmdbVideo {
@@ -428,3 +431,24 @@ export interface TmdbWatchProviderDetails {
provider_id: number;
provider_name: string;
}
export interface TmdbKeywordSearchResponse extends TmdbPaginatedResponse {
results: TmdbKeyword[];
}
// We have production companies, but the company search results return less data
export interface TmdbCompany {
id: number;
logo_path?: string;
name: string;
}
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
results: TmdbCompany[];
}
export interface TmdbWatchProviderRegion {
iso_3166_1: string;
english_name: string;
native_name: string;
}

View File

@@ -0,0 +1,98 @@
import type DiscoverSlider from '@server/entity/DiscoverSlider';
export enum DiscoverSliderType {
RECENTLY_ADDED = 1,
RECENT_REQUESTS,
PLEX_WATCHLIST,
TRENDING,
POPULAR_MOVIES,
MOVIE_GENRES,
UPCOMING_MOVIES,
STUDIOS,
POPULAR_TV,
TV_GENRES,
UPCOMING_TV,
NETWORKS,
TMDB_MOVIE_KEYWORD,
TMDB_MOVIE_GENRE,
TMDB_TV_KEYWORD,
TMDB_TV_GENRE,
TMDB_SEARCH,
TMDB_STUDIO,
TMDB_NETWORK,
}
export const defaultSliders: Partial<DiscoverSlider>[] = [
{
type: DiscoverSliderType.RECENTLY_ADDED,
enabled: true,
isBuiltIn: true,
order: 0,
},
{
type: DiscoverSliderType.RECENT_REQUESTS,
enabled: true,
isBuiltIn: true,
order: 1,
},
{
type: DiscoverSliderType.PLEX_WATCHLIST,
enabled: true,
isBuiltIn: true,
order: 2,
},
{
type: DiscoverSliderType.TRENDING,
enabled: true,
isBuiltIn: true,
order: 3,
},
{
type: DiscoverSliderType.POPULAR_MOVIES,
enabled: true,
isBuiltIn: true,
order: 4,
},
{
type: DiscoverSliderType.MOVIE_GENRES,
enabled: true,
isBuiltIn: true,
order: 5,
},
{
type: DiscoverSliderType.UPCOMING_MOVIES,
enabled: true,
isBuiltIn: true,
order: 6,
},
{
type: DiscoverSliderType.STUDIOS,
enabled: true,
isBuiltIn: true,
order: 7,
},
{
type: DiscoverSliderType.POPULAR_TV,
enabled: true,
isBuiltIn: true,
order: 8,
},
{
type: DiscoverSliderType.TV_GENRES,
enabled: true,
isBuiltIn: true,
order: 9,
},
{
type: DiscoverSliderType.UPCOMING_TV,
enabled: true,
isBuiltIn: true,
order: 10,
},
{
type: DiscoverSliderType.NETWORKS,
enabled: true,
isBuiltIn: true,
order: 11,
},
];

View File

@@ -34,7 +34,7 @@ const dataSource = new DataSource(
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
);
export const getRepository = <Entity>(
export const getRepository = <Entity extends object>(
target: EntityTarget<Entity>
): Repository<Entity> => {
return dataSource.getRepository(target);

View File

@@ -0,0 +1,69 @@
import type { DiscoverSliderType } from '@server/constants/discover';
import { defaultSliders } from '@server/constants/discover';
import { getRepository } from '@server/datasource';
import logger from '@server/logger';
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
class DiscoverSlider {
public static async bootstrapSliders(): Promise<void> {
const sliderRepository = getRepository(DiscoverSlider);
for (const slider of defaultSliders) {
const existingSlider = await sliderRepository.findOne({
where: {
type: slider.type,
},
});
if (!existingSlider) {
logger.info('Creating built-in discovery slider', {
label: 'Discover Slider',
slider,
});
await sliderRepository.save(new DiscoverSlider(slider));
}
}
}
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'int' })
public type: DiscoverSliderType;
@Column({ type: 'int' })
public order: number;
@Column({ default: false })
public isBuiltIn: boolean;
@Column({ default: true })
public enabled: boolean;
@Column({ nullable: true })
// Title is not required for built in sliders because we will
// use translations for them.
public title?: string;
@Column({ nullable: true })
public data?: string;
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<DiscoverSlider>) {
Object.assign(this, init);
}
}
export default DiscoverSlider;

View File

@@ -200,21 +200,20 @@ class Media {
const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
if (this.jellyfinMediaId) {
this.mediaUrl = new URL(
`/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`,
jellyfinHost
).href;
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
}
if (this.jellyfinMediaId4k) {
this.mediaUrl4k = new URL(
`/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`,
jellyfinHost
).href;
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
}
}
}

View File

@@ -767,7 +767,16 @@ export class MediaRequest {
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
throw new Error('Media already available');
logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
}
const radarrMovieOptions: RadarrMovieOptions = {
@@ -908,7 +917,16 @@ export class MediaRequest {
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
throw new Error('Media already available');
logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
}
const tmdb = new TheMovieDb();

View File

@@ -39,7 +39,7 @@ export class User {
return users.map((u) => u.filter(showFiltered));
}
static readonly filteredFields: string[] = ['email'];
static readonly filteredFields: string[] = ['email', 'plexId'];
public displayName: string;
@@ -76,7 +76,7 @@ export class User {
@Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType;
@Column({ nullable: true })
@Column({ nullable: true, select: true })
public plexId?: number;
@Column({ nullable: true })

View File

@@ -1,5 +1,6 @@
import PlexAPI from '@server/api/plexapi';
import dataSource, { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import { Session } from '@server/entity/Session';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
@@ -17,6 +18,7 @@ import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import routes from '@server/routes';
import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
@@ -104,6 +106,9 @@ app
);
}
// Bootstrap Discovery Sliders
await DiscoverSlider.bootstrapSliders();
const server = express();
if (settings.main.trustProxy) {
server.enable('trust proxy');
@@ -186,6 +191,9 @@ app
next();
});
server.use('/api/v1', routes);
server.use('/imageproxy', imageproxy);
server.get('*', (req, res) => handle(req, res));
server.use(
(

View File

@@ -54,6 +54,11 @@ export interface CacheItem {
};
}
export interface CacheResponse {
apiCaches: CacheItem[];
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
}
export interface StatusResponse {
version: string;
commitTag: string;

View File

@@ -257,8 +257,19 @@ class JobJellyfinSync {
//use for loop to make sure this loop _completes_ in full
//before the next section
for (const episode of episodes) {
let episodeCount = 1;
// count number of combined episodes
if (
episode.IndexNumber !== undefined &&
episode.IndexNumberEnd !== undefined
) {
episodeCount =
episode.IndexNumberEnd - episode.IndexNumber + 1;
}
if (!this.enable4kShow) {
totalStandard++;
totalStandard += episodeCount;
} else {
const ExtendedEpisodeData = await this.jfClient.getItemData(
episode.Id
@@ -268,10 +279,10 @@ class JobJellyfinSync {
return MediaSource.MediaStreams.some((MediaStream) => {
if (MediaStream.Type === 'Video') {
if (MediaStream.Width ?? 0 < 2000) {
totalStandard++;
totalStandard += episodeCount;
}
} else {
total4k++;
total4k += episodeCount;
}
});
});

View File

@@ -1,5 +1,6 @@
import { MediaServerType } from '@server/constants/server';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr';
@@ -181,5 +182,21 @@ export const startJobs = (): void => {
}),
});
// Run image cache cleanup every 5 minutes
scheduledJobs.push({
id: 'image-cache-cleanup',
name: 'Image Cache Cleanup',
type: 'process',
interval: 'long',
cronSchedule: jobs['image-cache-cleanup'].schedule,
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
logger.info('Starting scheduled job: Image Cache Cleanup', {
label: 'Jobs',
});
// Clean TMDB image cache
ImageProxy.clearCache('tmdb');
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};

View File

@@ -5,6 +5,12 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { uniqWith } from 'lodash';
interface EpisodeNumberResult {
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber: number;
id: number;
}
export interface DownloadingItem {
mediaType: MediaType;
externalId: number;
@@ -14,6 +20,7 @@ export interface DownloadingItem {
timeLeft: string;
estimatedCompletionTime: Date;
title: string;
episode?: EpisodeNumberResult;
}
class DownloadTracker {
@@ -164,6 +171,7 @@ class DownloadTracker {
status: item.status,
timeLeft: item.timeleft,
title: item.title,
episode: item.episode,
}));
if (queueItems.length > 0) {

270
server/lib/imageproxy.ts Normal file
View File

@@ -0,0 +1,270 @@
import logger from '@server/logger';
import axios from 'axios';
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
import { createHash } from 'crypto';
import { promises } from 'fs';
import path, { join } from 'path';
type ImageResponse = {
meta: {
revalidateAfter: number;
curRevalidate: number;
isStale: boolean;
etag: string;
extension: string;
cacheKey: string;
cacheMiss: boolean;
};
imageBuffer: Buffer;
};
class ImageProxy {
public static async clearCache(key: string) {
let deletedImages = 0;
const cacheDirectory = path.join(
__dirname,
'../../config/cache/images/',
key
);
const files = await promises.readdir(cacheDirectory);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath);
for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt);
const now = Date.now();
if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile));
deletedImages += 1;
}
}
}
}
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
label: 'Image Cache',
});
}
public static async getImageStats(
key: string
): Promise<{ size: number; imageCount: number }> {
const cacheDirectory = path.join(
__dirname,
'../../config/cache/images/',
key
);
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
return {
size: imageTotalSize,
imageCount,
};
}
private static async getDirectorySize(dir: string): Promise<number> {
const files = await promises.readdir(dir, {
withFileTypes: true,
});
const paths = files.map(async (file) => {
const path = join(dir, file.name);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isFile()) {
const { size } = await promises.stat(path);
return size;
}
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
}
private static async getImageCount(dir: string) {
const files = await promises.readdir(dir);
return files.length;
}
private axios;
private cacheVersion;
private key;
constructor(
key: string,
baseUrl: string,
options: {
cacheVersion?: number;
rateLimitOptions?: rateLimitOptions;
} = {}
) {
this.cacheVersion = options.cacheVersion ?? 1;
this.key = key;
this.axios = axios.create({
baseURL: baseUrl,
});
if (options.rateLimitOptions) {
this.axios = rateLimit(this.axios, options.rateLimitOptions);
}
}
public async getImage(path: string): Promise<ImageResponse> {
const cacheKey = this.getCacheKey(path);
const imageResponse = await this.get(cacheKey);
if (!imageResponse) {
const newImage = await this.set(path, cacheKey);
if (!newImage) {
throw new Error('Failed to load image');
}
return newImage;
}
// If the image is stale, we will revalidate it in the background.
if (imageResponse.meta.isStale) {
this.set(path, cacheKey);
}
return imageResponse;
}
private async get(cacheKey: string): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
const now = Date.now();
for (const file of files) {
const [maxAgeSt, expireAtSt, etag, extension] = file.split('.');
const buffer = await promises.readFile(join(directory, file));
const expireAt = Number(expireAtSt);
const maxAge = Number(maxAgeSt);
return {
meta: {
curRevalidate: maxAge,
revalidateAfter: maxAge * 1000 + now,
isStale: now > expireAt,
etag,
extension,
cacheKey,
cacheMiss: false,
},
imageBuffer: buffer,
};
}
} catch (e) {
// No files. Treat as empty cache.
}
return null;
}
private async set(
path: string,
cacheKey: string
): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const response = await this.axios.get(path, {
responseType: 'arraybuffer',
});
const buffer = Buffer.from(response.data, 'binary');
const extension = path.split('.').pop() ?? '';
const maxAge = Number(
(response.headers['cache-control'] ?? '0').split('=')[1]
);
const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.etag ?? '').replace(/"/g, '');
await this.writeToCacheDir(
directory,
extension,
maxAge,
expireAt,
buffer,
etag
);
return {
meta: {
curRevalidate: maxAge,
revalidateAfter: expireAt,
isStale: false,
etag,
extension,
cacheKey,
cacheMiss: true,
},
imageBuffer: buffer,
};
} catch (e) {
logger.debug('Something went wrong caching image.', {
label: 'Image Cache',
errorMessage: e.message,
});
return null;
}
}
private async writeToCacheDir(
dir: string,
extension: string,
maxAge: number,
expireAt: number,
buffer: Buffer,
etag: string
) {
const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`);
await promises.rm(dir, { force: true, recursive: true }).catch(() => {
// do nothing
});
await promises.mkdir(dir, { recursive: true });
await promises.writeFile(filename, buffer);
}
private getCacheKey(path: string) {
return this.getHash([this.key, this.cacheVersion, path]);
}
private getHash(items: (string | number | Buffer)[]) {
const hash = createHash('sha256');
for (const item of items) {
if (typeof item === 'number') hash.update(String(item));
else {
hash.update(item);
}
}
// See https://en.wikipedia.org/wiki/Base64#Filenames
return hash.digest('base64').replace(/\//g, '-');
}
private getCacheDirectory() {
return path.join(__dirname, '../../config/cache/images/', this.key);
}
}
export default ImageProxy;

View File

@@ -38,7 +38,7 @@ export interface PlexSettings {
export interface JellyfinSettings {
name: string;
hostname?: string;
hostname: string;
externalHostname?: string;
libraries: Library[];
serverId: string;
@@ -263,7 +263,8 @@ export type JobId =
| 'download-sync'
| 'download-sync-reset'
| 'jellyfin-recently-added-sync'
| 'jellyfin-full-sync';
| 'jellyfin-full-sync'
| 'image-cache-cleanup';
interface AllSettings {
clientId: string;
@@ -446,6 +447,9 @@ class Settings {
'jellyfin-full-sync': {
schedule: '0 0 3 * * *',
},
'image-cache-cleanup': {
schedule: '0 0 5 * * *',
},
},
};
if (initialSettings) {

View File

@@ -0,0 +1,15 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDiscoverSlider1672041273674 implements MigrationInterface {
name = 'AddDiscoverSlider1672041273674';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "discover_slider"`);
}
}

View File

@@ -9,6 +9,7 @@ import type {
Crew,
ExternalIds,
Genre,
Keyword,
ProductionCompany,
WatchProviders,
} from './common';
@@ -83,6 +84,7 @@ export interface MovieDetails {
externalIds: ExternalIds;
mediaUrl?: string;
watchProviders?: WatchProviders[];
keywords: Keyword[];
}
export const mapProductionCompany = (
@@ -142,4 +144,8 @@ export const mapMovieDetails = (
externalIds: mapExternalIds(movie.external_ids),
mediaInfo: media,
watchProviders: mapWatchProviders(movie['watch/providers']?.results ?? {}),
keywords: movie.keywords.keywords.map((keyword) => ({
id: keyword.id,
name: keyword.name,
})),
});

View File

@@ -89,13 +89,28 @@ authRoutes.post('/plex', async (req, res, next) => {
await userRepository.save(user);
} else {
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true, plexId: true },
select: { id: true, plexToken: true, plexId: true, email: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (!account.id) {
logger.error('Plex ID was missing from Plex.tv response', {
label: 'API',
ip: req.ip,
email: account.email,
plexUsername: account.username,
});
return next({
status: 500,
message: 'Something went wrong. Try again.',
});
}
if (
account.id === mainUser.plexId ||
(account.email === mainUser.email && !mainUser.plexId) ||
(await mainPlexTv.checkUserAccess(account.id))
) {
if (user) {
@@ -226,7 +241,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
const hostname =
settings.jellyfin.hostname !== ''
? settings.jellyfin.hostname
: body.hostname;
: body.hostname ?? '';
const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
@@ -244,11 +259,15 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
}
// First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const account = await jellyfinserver.login(body.username, body.password);
// Next let's see if the user already exists
user = await userRepository.findOne({
@@ -263,10 +282,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
// Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) {
user.avatar = new URL(
`/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`,
jellyfinHost
).href;
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
} else {
user.avatar = '/os_logo_square.png';
}
@@ -312,10 +328,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? new URL(
`/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`,
jellyfinHost
).href
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: '/os_logo_square.png',
userType: UserType.JELLYFIN,
});
@@ -345,10 +358,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinAuthToken: account.AccessToken,
permissions: settings.main.defaultPermissions,
avatar: account.User.PrimaryImageTag
? new URL(
`/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`,
jellyfinHost
).href
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: '/os_logo_square.png',
userType: UserType.JELLYFIN,
});

View File

@@ -1,5 +1,7 @@
import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
@@ -20,6 +22,7 @@ import { mapNetwork } from '@server/models/Tv';
import { isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import { sortBy } from 'lodash';
import { z } from 'zod';
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const settings = getSettings();
@@ -46,25 +49,76 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const discoverRoutes = Router();
const QueryFilterOptions = z.object({
page: z.coerce.string().optional(),
sortBy: z.coerce.string().optional(),
primaryReleaseDateGte: z.coerce.string().optional(),
primaryReleaseDateLte: z.coerce.string().optional(),
firstAirDateGte: z.coerce.string().optional(),
firstAirDateLte: z.coerce.string().optional(),
studio: z.coerce.string().optional(),
genre: z.coerce.string().optional(),
keywords: z.coerce.string().optional(),
language: z.coerce.string().optional(),
withRuntimeGte: z.coerce.string().optional(),
withRuntimeLte: z.coerce.string().optional(),
voteAverageGte: z.coerce.string().optional(),
voteAverageLte: z.coerce.string().optional(),
network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
discoverRoutes.get('/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const query = QueryFilterOptions.parse(req.query);
const keywords = query.keywords;
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: req.query.genre ? Number(req.query.genre) : undefined,
studio: req.query.studio ? Number(req.query.studio) : undefined,
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
originalLanguage: query.language,
genre: query.genre,
studio: query.studio,
primaryReleaseDateLte: query.primaryReleaseDateLte
? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0]
: undefined,
primaryReleaseDateGte: query.primaryReleaseDateGte
? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0]
: undefined,
keywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
}
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
keywords: keywordData,
results: data.results.map((result) =>
mapMovieResult(
result,
@@ -163,7 +217,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId),
genre: req.params.genreId as string,
});
const media = await Media.getRelatedMedia(
@@ -210,7 +264,7 @@ discoverRoutes.get<{ studioId: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
studio: Number(req.params.studioId),
studio: req.params.studioId as string,
});
const media = await Media.getRelatedMedia(
@@ -296,21 +350,50 @@ discoverRoutes.get('/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const query = QueryFilterOptions.parse(req.query);
const keywords = query.keywords;
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: req.query.genre ? Number(req.query.genre) : undefined,
network: req.query.network ? Number(req.query.network) : undefined,
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
genre: query.genre,
network: query.network ? Number(query.network) : undefined,
firstAirDateLte: query.firstAirDateLte
? new Date(query.firstAirDateLte).toISOString().split('T')[0]
: undefined,
firstAirDateGte: query.firstAirDateGte
? new Date(query.firstAirDateGte).toISOString().split('T')[0]
: undefined,
originalLanguage: query.language,
keywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
}
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
keywords: keywordData,
results: data.results.map((result) =>
mapTvResult(
result,
@@ -408,7 +491,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId),
genre: req.params.genreId,
});
const media = await Media.getRelatedMedia(
@@ -643,7 +726,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverMovies({ genre: genre.id });
const genreData = await tmdb.getDiscoverMovies({
genre: genre.id.toString(),
});
mappedGenres.push({
id: genre.id,
@@ -685,7 +770,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverTv({ genre: genre.id });
const genreData = await tmdb.getDiscoverTv({
genre: genre.id.toString(),
});
mappedGenres.push({
id: genre.id,

View File

@@ -0,0 +1,39 @@
import ImageProxy from '@server/lib/imageproxy';
import logger from '@server/logger';
import { Router } from 'express';
const router = Router();
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
/**
* Image Proxy
*/
router.get('/*', async (req, res) => {
const imagePath = req.path.replace('/image', '');
try {
const imageData = await tmdbImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy image', {
imagePath,
errorMessage: e.message,
});
res.status(500).send();
}
});
export default router;

View File

@@ -4,11 +4,14 @@ import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapWatchProviderDetails } from '@server/models/common';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import settingsRoutes from '@server/routes/settings';
@@ -102,6 +105,13 @@ router.get('/settings/public', async (req, res) => {
return res.status(200).json(settings.fullPublicSettings);
}
});
router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const sliders = await sliderRepository.find({ order: { order: 'ASC' } });
return res.json(sliders);
});
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
@@ -269,6 +279,87 @@ router.get('/backdrops', async (req, res, next) => {
}
});
router.get('/keyword/:keywordId', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getKeywordDetails({
keywordId: Number(req.params.keywordId),
});
return res.status(200).json(result);
} catch (e) {
logger.debug('Something went wrong retrieving keyword data', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve keyword data.',
});
}
});
router.get('/watchproviders/regions', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getAvailableWatchProviderRegions({});
return res.status(200).json(result);
} catch (e) {
logger.debug('Something went wrong retrieving watch provider regions', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve watch provider regions.',
});
}
});
router.get('/watchproviders/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getMovieWatchProviders({
watchRegion: req.query.watchRegion as string,
});
return res.status(200).json(mapWatchProviderDetails(result));
} catch (e) {
logger.debug('Something went wrong retrieving movie watch providers', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve movie watch providers.',
});
}
});
router.get('/watchproviders/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getTvWatchProviders({
watchRegion: req.query.watchRegion as string,
});
return res.status(200).json(mapWatchProviderDetails(result));
} catch (e) {
logger.debug('Something went wrong retrieving tv watch providers', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve tv watch providers.',
});
}
});
router.get('/', (_req, res) => {
return res.status(200).json({
api: 'Overseerr API',

View File

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

View File

@@ -1,7 +1,5 @@
import TheMovieDb from '@server/api/themoviedb';
import { MediaStatus } from '@server/constants/media';
import Media from '@server/entity/Media';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import {
mapCastCredits,
@@ -36,7 +34,6 @@ personRoutes.get('/:id', async (req, res, next) => {
personRoutes.get('/:id/combined_credits', async (req, res, next) => {
const tmdb = new TheMovieDb();
const settings = getSettings();
try {
const combinedCredits = await tmdb.getPersonCombinedCredits({
@@ -44,30 +41,14 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
language: req.locale ?? (req.query.language as string),
});
let castMedia = await Media.getRelatedMedia(
const castMedia = await Media.getRelatedMedia(
combinedCredits.cast.map((result) => result.id)
);
let crewMedia = await Media.getRelatedMedia(
const crewMedia = await Media.getRelatedMedia(
combinedCredits.crew.map((result) => result.id)
);
if (settings.main.hideAvailable) {
castMedia = castMedia.filter(
(media) =>
(media.mediaType === 'movie' || media.mediaType === 'tv') &&
media.status !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.PARTIALLY_AVAILABLE
);
crewMedia = crewMedia.filter(
(media) =>
(media.mediaType === 'movie' || media.mediaType === 'tv') &&
media.status !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
return res.status(200).json({
cast: combinedCredits.cast
.map((result) =>

View File

@@ -492,8 +492,10 @@ requestRoutes.post<{
relations: { requestedBy: true, modifiedBy: true },
});
await request.updateParentStatus();
await request.sendMedia();
// this also triggers updating the parent media's status & sending to *arr
request.status = MediaRequestStatus.APPROVED;
await requestRepository.save(request);
return res.status(200).json(request);
} catch (e) {
logger.error('Error processing request retry', {

View File

@@ -56,4 +56,50 @@ searchRoutes.get('/', async (req, res, next) => {
}
});
searchRoutes.get('/keyword', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const results = await tmdb.searchKeyword({
query: req.query.query as string,
page: Number(req.query.page),
});
return res.status(200).json(results);
} catch (e) {
logger.debug('Something went wrong retrieving keyword search results', {
label: 'API',
errorMessage: e.message,
query: req.query.query,
});
return next({
status: 500,
message: 'Unable to retrieve keyword search results.',
});
}
});
searchRoutes.get('/company', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const results = await tmdb.searchCompany({
query: req.query.query as string,
page: Number(req.query.page),
});
return res.status(200).json(results);
} catch (e) {
logger.debug('Something went wrong retrieving company search results', {
label: 'API',
errorMessage: e.message,
query: req.query.query,
});
return next({
status: 500,
message: 'Unable to retrieve company search results.',
});
}
});
export default searchRoutes;

View File

@@ -0,0 +1,131 @@
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import logger from '@server/logger';
import { Router } from 'express';
const discoverSettingRoutes = Router();
discoverSettingRoutes.post('/', async (req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const sliders = req.body as DiscoverSlider[];
if (!Array.isArray(sliders)) {
return res.status(400).json({ message: 'Invalid request body.' });
}
for (let x = 0; x < sliders.length; x++) {
const slider = sliders[x];
const existingSlider = await sliderRepository.findOne({
where: {
id: slider.id,
},
});
if (existingSlider && slider.id) {
existingSlider.enabled = slider.enabled;
existingSlider.order = x;
// Only allow changes to the following when the slider is not built in
if (!existingSlider.isBuiltIn) {
existingSlider.title = slider.title;
existingSlider.data = slider.data;
existingSlider.type = slider.type;
}
await sliderRepository.save(existingSlider);
} else {
const newSlider = new DiscoverSlider({
isBuiltIn: false,
data: slider.data,
title: slider.title,
enabled: slider.enabled,
order: x,
type: slider.type,
});
await sliderRepository.save(newSlider);
}
}
return res.json(sliders);
});
discoverSettingRoutes.post('/add', async (req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const slider = req.body as DiscoverSlider;
const newSlider = new DiscoverSlider({
isBuiltIn: false,
data: slider.data,
title: slider.title,
enabled: false,
order: -1,
type: slider.type,
});
await sliderRepository.save(newSlider);
return res.json(newSlider);
});
discoverSettingRoutes.get('/reset', async (_req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
await sliderRepository.clear();
await DiscoverSlider.bootstrapSliders();
return res.status(204).send();
});
discoverSettingRoutes.put('/:sliderId', async (req, res, next) => {
const sliderRepository = getRepository(DiscoverSlider);
const slider = req.body as DiscoverSlider;
try {
const existingSlider = await sliderRepository.findOneOrFail({
where: {
id: Number(req.params.sliderId),
},
});
// Only allow changes to the following when the slider is not built in
if (!existingSlider.isBuiltIn) {
existingSlider.title = slider.title;
existingSlider.data = slider.data;
existingSlider.type = slider.type;
}
await sliderRepository.save(existingSlider);
return res.status(200).json(existingSlider);
} catch (e) {
logger.error('Something went wrong updating a slider.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Slider not found or cannot be updated.' });
}
});
discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => {
const sliderRepository = getRepository(DiscoverSlider);
try {
const slider = await sliderRepository.findOneOrFail({
where: { id: Number(req.params.sliderId), isBuiltIn: false },
});
await sliderRepository.remove(slider);
return res.status(204).send();
} catch (e) {
logger.error('Something went wrong deleting a slider.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Slider not found or cannot be deleted.' });
}
});
export default discoverSettingRoutes;

View File

@@ -16,12 +16,14 @@ import { jobJellyfinFullSync } from '@server/job/jellyfinsync';
import { scheduledJobs } from '@server/job/schedule';
import type { AvailableCacheIds } from '@server/lib/cache';
import cacheManager from '@server/lib/cache';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
import { plexFullScanner } from '@server/lib/scanners/plex';
import type { Library, MainSettings } from '@server/lib/settings';
import type { JobId, Library, MainSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { Router } from 'express';
@@ -41,6 +43,7 @@ const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
const filteredMainSettings = (
user: User,
@@ -307,11 +310,14 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings();
const { hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
@@ -330,10 +336,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
username: user.Name,
id: user.Id,
thumb: user.PrimaryImageTag
? new URL(
`/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`,
jellyfinHost
).href
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: '/os_logo_square.png',
email: user.Name,
}));
@@ -604,7 +607,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
});
});
settingsRoutes.post<{ jobId: string }>(
settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/cancel',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -631,7 +634,7 @@ settingsRoutes.post<{ jobId: string }>(
}
);
settingsRoutes.post<{ jobId: string }>(
settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/schedule',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -666,16 +669,23 @@ settingsRoutes.post<{ jobId: string }>(
}
);
settingsRoutes.get('/cache', (req, res) => {
const caches = cacheManager.getAllCaches();
settingsRoutes.get('/cache', async (_req, res) => {
const cacheManagerCaches = cacheManager.getAllCaches();
return res.status(200).json(
Object.values(caches).map((cache) => ({
id: cache.id,
name: cache.name,
stats: cache.getStats(),
}))
);
const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({
id: cache.id,
name: cache.name,
stats: cache.getStats(),
}));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
},
});
});
settingsRoutes.post<{ cacheId: AvailableCacheIds }>(

View File

@@ -497,11 +497,14 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = [];
const { hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers();
@@ -525,10 +528,7 @@ router.post(
email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions,
avatar: jellyfinUser?.PrimaryImageTag
? new URL(
`/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`,
jellyfinHost
).href
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
: '/os_logo_square.png',
userType: UserType.JELLYFIN,
});

View File

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

View File

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

9
server/types/express-session.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import 'express-session';
// Declaration merging to apply our own types to SessionData
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
declare module 'express-session' {
interface SessionData {
userId: number;
}
}

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { User } from '@server/entity/User';
import type { NextFunction, Request, Response } from 'express';
import 'express-session';
declare global {
namespace Express {
@@ -16,11 +17,3 @@ declare global {
next: NextFunction
) => Promise<void | NextFunction> | void | NextFunction;
}
// Declaration merging to apply our own types to SessionData
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
declare module 'express-session' {
export interface SessionData {
userId: number;
}
}

View File

@@ -1,10 +1,11 @@
name: overseerr
adopt-info: overseerr
name: jellyseerr
adopt-info: jellyseerr
license: MIT
summary: Request management and media discovery tool for the Plex ecosystem.
summary: Request management and media discovery tool for media servers
description: >
Overseerr is a free and open source software application for managing requests for your media library.
It integrates with your existing services such as Sonarr, Radarr and Plex!
Jellyseerr is a free and open source software application for managing requests for your media library.
It is a a fork of Overseerr built to bring support for & focusing mainly on Jellyfin & Emby media servers!
It integrates with your existing services such as Sonarr, Radarr, and Jellyfin/Emby/Plex.
base: core18
confinement: strict
@@ -14,7 +15,7 @@ architectures:
- build-on: armhf
parts:
overseerr:
jellyseerr:
plugin: nodejs
nodejs-version: '16.17.0'
nodejs-package-manager: 'yarn'
@@ -36,7 +37,7 @@ parts:
override-pull: |
snapcraftctl pull
# Get information to determine snap grade and version
git config --global --add safe.directory /data/parts/overseerr/src
git config --global --add safe.directory /data/parts/jellyyseerr/src
#setup yarn.rc
echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc
BRANCH=$(git rev-parse --abbrev-ref HEAD)

View File

@@ -10,13 +10,13 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { DownloadIcon } from '@heroicons/react/outline';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import type { Collection } from '@server/models/Collection';
import { uniq } from 'lodash';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
@@ -51,6 +51,28 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
const { data: genres } =
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
const [downloadStatus, downloadStatus4k] = useMemo(() => {
return [
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
),
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
}, [data?.parts]);
const [titles, titles4k] = useMemo(() => {
return [
data?.parts
.filter((media) => (media.mediaInfo?.downloadStatus ?? []).length > 0)
.map((title) => title.title),
data?.parts
.filter((media) => (media.mediaInfo?.downloadStatus4k ?? []).length > 0)
.map((title) => title.title),
];
}, [data?.parts]);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -205,6 +227,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<div className="media-status">
<StatusBadge
status={collectionStatus}
downloadItem={downloadStatus}
title={titles}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
@@ -218,6 +242,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
) && (
<StatusBadge
status={collectionStatus4k}
downloadItem={downloadStatus4k}
title={titles4k}
is4k
inProgress={data.parts.some(
(part) =>
@@ -250,7 +276,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
}}
text={
<>
<DownloadIcon />
<ArrowDownTrayIcon />
<span>
{intl.formatMessage(
hasRequestable
@@ -269,7 +295,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
setIs4k(true);
}}
>
<DownloadIcon />
<ArrowDownTrayIcon />
<span>
{intl.formatMessage(messages.requestcollection4k)}
</span>

View File

@@ -1,8 +1,8 @@
import {
ExclamationIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/react/solid';
} from '@heroicons/react/24/solid';
interface AlertProps {
title?: React.ReactNode;
@@ -16,7 +16,7 @@ const Alert = ({ title, children, type }: AlertProps) => {
'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20',
titleColor: 'text-yellow-100',
textColor: 'text-yellow-300',
svg: <ExclamationIcon className="h-5 w-5" />,
svg: <ExclamationTriangleIcon className="h-5 w-5" />,
};
switch (type) {

View File

@@ -46,7 +46,7 @@ function Button<P extends ElementTypes = 'button'>(
ref?: React.Ref<Element<P>>
): JSX.Element {
const buttonStyle = [
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
'inline-flex items-center justify-center border leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
];
switch (buttonType) {
case 'primary':
@@ -71,7 +71,7 @@ function Button<P extends ElementTypes = 'button'>(
break;
case 'ghost':
buttonStyle.push(
'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
'text-white bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
);
break;
default:

View File

@@ -1,7 +1,7 @@
import useClickOutside from '@app/hooks/useClickOutside';
import { withProperties } from '@app/utils/typeHelpers';
import { Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/solid';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
import { Fragment, useRef, useState } from 'react';

View File

@@ -1,18 +1,27 @@
import useSettings from '@app/hooks/useSettings';
import type { ImageProps } from 'next/image';
import type { ImageLoader, ImageProps } from 'next/image';
import Image from 'next/image';
const imageLoader: ImageLoader = ({ src }) => src;
/**
* The CachedImage component should be used wherever
* we want to offer the option to locally cache images.
*
* It uses the `next/image` Image component but overrides
* the `unoptimized` prop based on the application setting `cacheImages`.
**/
const CachedImage = (props: ImageProps) => {
const CachedImage = ({ src, ...props }: ImageProps) => {
const { currentSettings } = useSettings();
return <Image unoptimized={!currentSettings.cacheImages} {...props} />;
let imageUrl = src;
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
const parsedUrl = new URL(imageUrl);
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
}
}
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
};
export default CachedImage;

View File

@@ -1,6 +1,6 @@
import Button from '@app/components/Common/Button';
import useClickOutside from '@app/hooks/useClickOutside';
import { useRef, useState } from 'react';
import { forwardRef, useRef, useState } from 'react';
interface ConfirmButtonProps {
onClick: () => void;
@@ -9,50 +9,51 @@ interface ConfirmButtonProps {
children: React.ReactNode;
}
const ConfirmButton = ({
onClick,
children,
confirmText,
className,
}: ConfirmButtonProps) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
const ConfirmButton = forwardRef<HTMLButtonElement, ConfirmButtonProps>(
({ onClick, children, confirmText, className }, parentRef) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
ref={parentRef}
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
&nbsp;
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
{children}
</div>
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
};
<div
ref={ref}
className={`relative inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
>
{children}
</div>
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? 'translate-y-0 opacity-100'
: 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
}
);
ConfirmButton.displayName = 'ConfirmButton';
export default ConfirmButton;

View File

@@ -0,0 +1,113 @@
import Tooltip from '@app/components/Common/Tooltip';
import useDebouncedState from '@app/hooks/useDebouncedState';
import { useEffect, useRef } from 'react';
type MultiRangeSliderProps = {
min: number;
max: number;
defaultMinValue?: number;
defaultMaxValue?: number;
subText?: string;
onUpdateMin: (min: number) => void;
onUpdateMax: (max: number) => void;
};
const MultiRangeSlider = ({
min,
max,
defaultMinValue,
defaultMaxValue,
subText,
onUpdateMin,
onUpdateMax,
}: MultiRangeSliderProps) => {
const touched = useRef(false);
const [valueMin, finalValueMin, setValueMin] = useDebouncedState(
defaultMinValue ?? min
);
const [valueMax, finalValueMax, setValueMax] = useDebouncedState(
defaultMaxValue ?? max
);
const minThumb = ((valueMin - min) / (max - min)) * 100;
const maxThumb = ((valueMax - min) / (max - min)) * 100;
useEffect(() => {
if (touched.current) {
onUpdateMin(finalValueMin);
}
}, [finalValueMin, onUpdateMin]);
useEffect(() => {
if (touched.current) {
onUpdateMax(finalValueMax);
}
}, [finalValueMax, onUpdateMax]);
useEffect(() => {
touched.current = false;
setValueMax(defaultMaxValue ?? max);
setValueMin(defaultMinValue ?? min);
}, [defaultMinValue, defaultMaxValue, setValueMax, setValueMin, min, max]);
return (
<div className={`relative ${subText ? 'h-8' : 'h-4'} w-full`}>
<Tooltip
content={valueMin.toString()}
tooltipConfig={{
placement: 'top',
}}
>
<input
type="range"
min={min}
max={max}
value={valueMin}
className={`pointer-events-none absolute h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 ${
valueMin >= valueMax && valueMin !== min ? 'z-30' : 'z-10'
}`}
onChange={(e) => {
const value = Number(e.target.value);
if (value <= valueMax) {
touched.current = true;
setValueMin(value);
}
}}
/>
</Tooltip>
<Tooltip content={valueMax}>
<input
type="range"
min={min}
max={max}
value={valueMax}
step="1"
className={`pointer-events-none absolute top-0 left-0 right-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
onChange={(e) => {
const value = Number(e.target.value);
if (value >= valueMin) {
touched.current = true;
setValueMax(value);
}
}}
/>
</Tooltip>
<div
className="pointer-events-none absolute top-0 z-30 ml-1 mr-1 h-2 bg-indigo-500"
style={{
left: `${minThumb}%`,
right: `${100 - maxThumb}%`,
}}
/>
{subText && (
<div className="relative top-4 z-30 flex w-full justify-center text-sm text-gray-400">
<span>{subText}</span>
</div>
)}
</div>
);
};
export default MultiRangeSlider;

View File

@@ -1,4 +1,4 @@
import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/solid';
import { Field } from 'formik';
import { useState } from 'react';
@@ -43,7 +43,7 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
type="button"
className="input-action"
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
{isHidden ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</>
);

View File

@@ -0,0 +1,38 @@
type SlideCheckboxProps = {
onClick: () => void;
checked?: boolean;
};
const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
return (
<span
role="checkbox"
tabIndex={0}
aria-checked={false}
onClick={() => {
onClick();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
onClick();
}
}}
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none`}
>
<span
aria-hidden="true"
className={`${
checked ? 'bg-indigo-500' : 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
checked ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
);
};
export default SlideCheckbox;

View File

@@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import { Transition } from '@headlessui/react';
import { XIcon } from '@heroicons/react/outline';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { Fragment, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
@@ -67,11 +67,11 @@ const SlideOver = ({
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="slideover h-full w-screen max-w-md p-2 sm:p-4"
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
ref={slideoverRef}
onClick={(e) => e.stopPropagation()}
>
<div className="hide-scrollbar flex h-full flex-col overflow-y-scroll rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
<div className="flex h-full flex-col rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
<header className="space-y-1 border-b border-gray-700 py-4 px-4">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-overseerr text-2xl font-bold leading-7">
@@ -83,7 +83,7 @@ const SlideOver = ({
className="text-gray-200 transition duration-150 ease-in-out hover:text-white"
onClick={() => onClose()}
>
<XIcon className="h-6 w-6" />
<XMarkIcon className="h-6 w-6" />
</button>
</div>
</div>
@@ -95,8 +95,10 @@ const SlideOver = ({
</div>
)}
</header>
<div className="relative flex-1 px-4 py-6 text-white">
{children}
<div className="hide-scrollbar flex flex-1 flex-col overflow-y-auto">
<div className="flex-1 px-4 py-6 text-white">
{children}
</div>
</div>
</div>
</div>

View File

@@ -1,41 +1,67 @@
import {
BellIcon,
CheckIcon,
ClockIcon,
MinusSmIcon,
} from '@heroicons/react/solid';
import Spinner from '@app/assets/spinner.svg';
import { CheckCircleIcon } from '@heroicons/react/20/solid';
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media';
interface StatusBadgeMiniProps {
status: MediaStatus;
is4k?: boolean;
inProgress?: boolean;
// Should the badge shrink on mobile to a smaller size? (TitleCard)
shrink?: boolean;
}
const StatusBadgeMini = ({ status, is4k = false }: StatusBadgeMiniProps) => {
const badgeStyle = ['w-5 rounded-full p-0.5 text-white ring-1'];
const StatusBadgeMini = ({
status,
is4k = false,
inProgress = false,
shrink = false,
}: StatusBadgeMiniProps) => {
const badgeStyle = [
`rounded-full bg-opacity-80 shadow-md ${
shrink ? 'w-4 sm:w-5 border p-0' : 'w-5 ring-1 p-0.5'
}`,
];
let indicatorIcon: React.ReactNode;
switch (status) {
case MediaStatus.PROCESSING:
badgeStyle.push('bg-indigo-500 ring-indigo-400');
badgeStyle.push(
'bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100'
);
indicatorIcon = <ClockIcon />;
break;
case MediaStatus.AVAILABLE:
badgeStyle.push('bg-green-500 ring-green-400');
indicatorIcon = <CheckIcon />;
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'
);
indicatorIcon = <CheckCircleIcon />;
break;
case MediaStatus.PENDING:
badgeStyle.push('bg-yellow-500 ring-yellow-400');
badgeStyle.push(
'bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100'
);
indicatorIcon = <BellIcon />;
break;
case MediaStatus.PARTIALLY_AVAILABLE:
badgeStyle.push('bg-green-500 ring-green-400');
indicatorIcon = <MinusSmIcon />;
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'
);
indicatorIcon = <MinusSmallIcon />;
break;
}
if (inProgress) {
indicatorIcon = <Spinner />;
}
return (
<div className="inline-flex whitespace-nowrap rounded-full text-xs font-semibold leading-5 ring-1 ring-gray-700">
<div
className={`relative inline-flex whitespace-nowrap rounded-full border-gray-700 text-xs font-semibold leading-5 ring-gray-700 ${
shrink ? '' : 'ring-1'
}`}
>
<div className={badgeStyle.join(' ')}>{indicatorIcon}</div>
{is4k && <span className="pl-1 pr-2 text-gray-200">4K</span>}
</div>

View File

@@ -0,0 +1,24 @@
import { TagIcon } from '@heroicons/react/24/outline';
import React from 'react';
type TagProps = {
children: React.ReactNode;
iconSvg?: JSX.Element;
};
const Tag = ({ children, iconSvg }: TagProps) => {
return (
<div className="inline-flex cursor-pointer items-center rounded-full bg-gray-800 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-600 transition hover:bg-gray-700">
{iconSvg ? (
React.cloneElement(iconSvg, {
className: 'mr-1 h-4 w-4',
})
) : (
<TagIcon className="mr-1 h-4 w-4" />
)}
<span>{children}</span>
</div>
);
};
export default Tag;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import type { Config } from 'react-popper-tooltip';
import { usePopperTooltip } from 'react-popper-tooltip';
@@ -6,9 +7,15 @@ type TooltipProps = {
content: React.ReactNode;
children: React.ReactElement;
tooltipConfig?: Partial<Config>;
className?: string;
};
const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
const Tooltip = ({
children,
content,
tooltipConfig,
className,
}: TooltipProps) => {
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
usePopperTooltip({
followCursor: true,
@@ -17,20 +24,30 @@ const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
...tooltipConfig,
});
const tooltipStyle = [
'z-50 text-sm absolute font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
];
if (className) {
tooltipStyle.push(className);
}
return (
<>
{React.cloneElement(children, { ref: setTriggerRef })}
{visible && content && (
<div
ref={setTooltipRef}
{...getTooltipProps({
className:
'z-50 text-sm font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
})}
>
{content}
</div>
)}
{visible &&
content &&
ReactDOM.createPortal(
<div
ref={setTooltipRef}
{...getTooltipProps({
className: tooltipStyle.join(' '),
})}
>
{content}
</div>,
document.body
)}
</>
);
};

View File

@@ -1,3 +1,4 @@
import CachedImage from '@app/components/Common/CachedImage';
import Link from 'next/link';
import { useState } from 'react';
@@ -30,11 +31,15 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
role="link"
tabIndex={0}
>
<img
src={image}
alt={name}
className="relative z-40 max-h-full max-w-full"
/>
<div className="relative h-full w-full">
<CachedImage
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'

View File

@@ -0,0 +1,28 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { BuildingOffice2Icon } from '@heroicons/react/24/outline';
import type { ProductionCompany, TvNetwork } from '@server/models/common';
import useSWR from 'swr';
type CompanyTagProps = {
type: 'studio' | 'network';
companyId: number;
};
const CompanyTag = ({ companyId, type }: CompanyTagProps) => {
const { data, error } = useSWR<TvNetwork | ProductionCompany>(
`/api/v1/${type}/${companyId}`
);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
return <Tag iconSvg={<BuildingOffice2Icon />}>{data?.name}</Tag>;
};
export default CompanyTag;

View File

@@ -0,0 +1,506 @@
import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import AsyncSelect from 'react-select/async';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
const messages = defineMessages({
addSlider: 'Add Slider',
editSlider: 'Edit Slider',
slidernameplaceholder: 'Slider Name',
providetmdbkeywordid: 'Provide a TMDB Keyword ID',
providetmdbgenreid: 'Provide a TMDB Genre ID',
providetmdbsearch: 'Provide a search query',
providetmdbstudio: 'Provide TMDB Studio ID',
providetmdbnetwork: 'Provide TMDB Network ID',
addsuccess: 'Created new slider and saved discover customization settings.',
addfail: 'Failed to create new slider.',
editsuccess: 'Edited slider and saved discover customization settings.',
editfail: 'Failed to edit slider.',
needresults: 'You need to have at least 1 result.',
validationDatarequired: 'You must provide a data value.',
validationTitlerequired: 'You must provide a title.',
addcustomslider: 'Create Custom Slider',
searchKeywords: 'Search keywords…',
searchGenres: 'Search genres…',
searchStudios: 'Search studios…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
});
type CreateSliderProps = {
onCreate: () => void;
slider?: Partial<DiscoverSlider>;
};
type CreateOption = {
type: DiscoverSliderType;
title: string;
dataUrl: string;
params?: string;
titlePlaceholderText: string;
dataPlaceholderText: string;
};
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [resultCount, setResultCount] = useState(0);
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
if (slider) {
const loadDefaultKeywords = async (): Promise<void> => {
if (!slider.data) {
return;
}
const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
setDefaultDataValue(
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))
);
};
const loadDefaultGenre = async (): Promise<void> => {
if (!slider.data) {
return;
}
const response = await axios.get<TmdbGenre[]>(
`/api/v1/genres/${
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
}`
);
const genre = response.data.find(
(genre) => genre.id === Number(slider.data)
);
setDefaultDataValue([
{
label: genre?.name ?? '',
value: genre?.id ?? 0,
},
]);
};
const loadDefaultCompany = async (): Promise<void> => {
if (!slider.data) {
return;
}
const response = await axios.get<ProductionCompany>(
`/api/v1/studio/${slider.data}`
);
const studio = response.data;
setDefaultDataValue([
{
label: studio.name ?? '',
value: studio.id ?? 0,
},
]);
};
switch (slider.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
case DiscoverSliderType.TMDB_TV_KEYWORD:
loadDefaultKeywords();
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
case DiscoverSliderType.TMDB_TV_GENRE:
loadDefaultGenre();
break;
case DiscoverSliderType.TMDB_STUDIO:
loadDefaultCompany();
break;
}
}
}, [slider]);
const CreateSliderSchema = Yup.object().shape({
title: Yup.string().required(
intl.formatMessage(messages.validationTitlerequired)
),
data: Yup.string().required(
intl.formatMessage(messages.validationDatarequired)
),
});
const updateResultCount = useCallback(
(count: number) => {
setResultCount(count);
},
[setResultCount]
);
const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadCompanyOptions = async (inputValue: string) => {
if (inputValue === '') {
return [];
}
const results = await axios.get<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadMovieGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/movie'
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadTvGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/tv'
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const options: CreateOption[] = [
{
type: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: intl.formatMessage(sliderTitles.tmdbmoviekeyword),
dataUrl: '/api/v1/discover/movies',
params: 'keywords=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
},
{
type: DiscoverSliderType.TMDB_TV_KEYWORD,
title: intl.formatMessage(sliderTitles.tmdbtvkeyword),
dataUrl: '/api/v1/discover/tv',
params: 'keywords=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
},
{
type: DiscoverSliderType.TMDB_MOVIE_GENRE,
title: intl.formatMessage(sliderTitles.tmdbmoviegenre),
dataUrl: '/api/v1/discover/movies/genre/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
},
{
type: DiscoverSliderType.TMDB_TV_GENRE,
title: intl.formatMessage(sliderTitles.tmdbtvgenre),
dataUrl: '/api/v1/discover/tv/genre/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
},
{
type: DiscoverSliderType.TMDB_STUDIO,
title: intl.formatMessage(sliderTitles.tmdbstudio),
dataUrl: '/api/v1/discover/movies/studio/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbstudio),
},
{
type: DiscoverSliderType.TMDB_NETWORK,
title: intl.formatMessage(sliderTitles.tmdbnetwork),
dataUrl: '/api/v1/discover/tv/network/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbnetwork),
},
{
type: DiscoverSliderType.TMDB_SEARCH,
title: intl.formatMessage(sliderTitles.tmdbsearch),
dataUrl: '/api/v1/search',
params: 'query=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
},
];
return (
<Formik
initialValues={
slider
? {
sliderType: slider.type,
title: slider.title,
data: slider.data,
}
: {
sliderType: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: '',
data: '',
}
}
validationSchema={CreateSliderSchema}
enableReinitialize
onSubmit={async (values, { resetForm }) => {
try {
if (slider) {
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
} else {
await axios.post('/api/v1/settings/discover/add', {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
}
addToast(
intl.formatMessage(
slider ? messages.editsuccess : messages.addsuccess
),
{
appearance: 'success',
autoDismiss: true,
}
);
onCreate();
resetForm();
} catch (e) {
addToast(
intl.formatMessage(slider ? messages.editfail : messages.addfail),
{
appearance: 'error',
autoDismiss: true,
}
);
}
}}
>
{({ values, isValid, isSubmitting, errors, touched, setFieldValue }) => {
const activeOption = options.find(
(option) => option.type === Number(values.sliderType)
);
let dataInput: React.ReactNode;
switch (activeOption?.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
case DiscoverSliderType.TMDB_TV_KEYWORD:
dataInput = (
<AsyncSelect
key={`keyword-select-${defaultDataValue}`}
inputId="data"
isMulti
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
inputValue === ''
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
defaultValue={defaultDataValue}
loadOptions={loadKeywordOptions}
placeholder={intl.formatMessage(messages.searchKeywords)}
onChange={(value) => {
const keywords = value.map((item) => item.value).join(',');
setFieldValue('data', keywords);
}}
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
dataInput = (
<AsyncSelect
key={`movie-genre-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadMovieGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
case DiscoverSliderType.TMDB_TV_GENRE:
dataInput = (
<AsyncSelect
key={`tv-genre-select-${defaultDataValue}}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadTvGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
case DiscoverSliderType.TMDB_STUDIO:
dataInput = (
<AsyncSelect
key={`studio-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadCompanyOptions}
placeholder={intl.formatMessage(messages.searchStudios)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
default:
dataInput = (
<Field
type="text"
name="data"
id="data"
placeholder={activeOption?.dataPlaceholderText}
/>
);
}
return (
<Form data-testid="create-discover-option-form">
<div className="flex flex-col space-y-2 text-gray-100">
<Field as="select" id="sliderType" name="sliderType">
{options.map((option) => (
<option value={option.type} key={`type-${option.type}`}>
{option.title}
</option>
))}
</Field>
<Field
type="text"
name="title"
id="title"
placeholder={activeOption?.titlePlaceholderText}
/>
{errors.title &&
touched.title &&
typeof errors.title === 'string' && (
<div className="error">{errors.title}</div>
)}
{dataInput}
{errors.data &&
touched.data &&
typeof errors.data === 'string' && (
<div className="error">{errors.data}</div>
)}
<div className="flex-1"></div>
{resultCount === 0 ? (
<Tooltip content={intl.formatMessage(messages.needresults)}>
<div>
<Button buttonType="primary" buttonSize="sm" disabled>
{intl.formatMessage(messages.addSlider)}
</Button>
</div>
</Tooltip>
) : (
<div>
<Button
buttonType="primary"
buttonSize="sm"
disabled={isSubmitting || !isValid}
>
{intl.formatMessage(
slider ? messages.editSlider : messages.addSlider
)}
</Button>
</div>
)}
</div>
{activeOption && values.title && values.data && (
<div className="relative py-4">
<MediaSlider
sliderKey={`preview-${values.title}`}
title={values.title}
url={activeOption?.dataUrl.replace(
'$value',
encodeURIExtraParams(values.data)
)}
extraParams={activeOption.params?.replace(
'$value',
encodeURIExtraParams(values.data)
)}
onNewTitles={updateResultCount}
/>
</div>
)}
</Form>
);
}}
</Formik>
);
};
export default CreateSlider;

View File

@@ -1,16 +1,20 @@
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovermovies: 'Popular Movies',
keywordMovies: '{keywordTitle} Movies',
});
const DiscoverMovies = () => {
const DiscoverMovieKeyword = () => {
const router = useRouter();
const intl = useIntl();
const {
@@ -21,13 +25,25 @@ const DiscoverMovies = () => {
titles,
fetchMore,
error,
} = useDiscover<MovieResult>('/api/v1/discover/movies');
firstResultData,
} = useDiscover<MovieResult, { keywords: TmdbKeyword[] }>(
`/api/v1/discover/movies`,
{
keywords: encodeURIExtraParams(router.query.keywords as string),
}
);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovermovies);
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.keywordMovies, {
keywordTitle: firstResultData?.keywords
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
.join(', '),
});
return (
<>
@@ -48,4 +64,4 @@ const DiscoverMovies = () => {
);
};
export default DiscoverMovies;
export default DiscoverMovieKeyword;

View File

@@ -0,0 +1,147 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { FilterOptions } from '@app/components/Discover/constants';
import {
countActiveFilters,
prepareFilterValues,
} from '@app/components/Discover/constants';
import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovermovies: 'Movies',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
sortPopularityAsc: 'Popularity Ascending',
sortPopularityDesc: 'Popularity Descending',
sortReleaseDateAsc: 'Release Date Ascending',
sortReleaseDateDesc: 'Release Date Descending',
sortTmdbRatingAsc: 'TMDB Rating Ascending',
sortTmdbRatingDesc: 'TMDB Rating Descending',
sortTitleAsc: 'Title (A-Z) Ascending',
sortTitleDesc: 'Title (Z-A) Descending',
});
const SortOptions: Record<string, TMDBSortOptions> = {
PopularityAsc: 'popularity.asc',
PopularityDesc: 'popularity.desc',
ReleaseDateAsc: 'release_date.asc',
ReleaseDateDesc: 'release_date.desc',
TmdbRatingAsc: 'vote_average.asc',
TmdbRatingDesc: 'vote_average.desc',
TitleAsc: 'original_title.asc',
TitleDesc: 'original_title.desc',
} as const;
const DiscoverMovies = () => {
const intl = useIntl();
const router = useRouter();
const updateQueryParams = useUpdateQueryParams({});
const preparedFilters = prepareFilterValues(router.query);
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<MovieResult, unknown, FilterOptions>(
'/api/v1/discover/movies',
preparedFilters
);
const [showFilters, setShowFilters] = useState(false);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovermovies);
return (
<>
<PageTitle title={title} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{title}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sortBy"
name="sortBy"
className="rounded-r-only"
value={preparedFilters.sortBy}
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
>
<option value={SortOptions.PopularityDesc}>
{intl.formatMessage(messages.sortPopularityDesc)}
</option>
<option value={SortOptions.PopularityAsc}>
{intl.formatMessage(messages.sortPopularityAsc)}
</option>
<option value={SortOptions.ReleaseDateDesc}>
{intl.formatMessage(messages.sortReleaseDateDesc)}
</option>
<option value={SortOptions.ReleaseDateAsc}>
{intl.formatMessage(messages.sortReleaseDateAsc)}
</option>
<option value={SortOptions.TmdbRatingDesc}>
{intl.formatMessage(messages.sortTmdbRatingDesc)}
</option>
<option value={SortOptions.TmdbRatingAsc}>
{intl.formatMessage(messages.sortTmdbRatingAsc)}
</option>
<option value={SortOptions.TitleAsc}>
{intl.formatMessage(messages.sortTitleAsc)}
</option>
<option value={SortOptions.TitleDesc}>
{intl.formatMessage(messages.sortTitleDesc)}
</option>
</select>
</div>
<FilterSlideover
type="movie"
currentFilters={preparedFilters}
onClose={() => setShowFilters(false)}
show={showFilters}
/>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<Button onClick={() => setShowFilters(true)} className="w-full">
<FunnelIcon />
<span>
{intl.formatMessage(messages.activefilters, {
count: countActiveFilters(preparedFilters),
})}
</span>
</Button>
</div>
</div>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovies;

View File

@@ -0,0 +1,334 @@
import Button from '@app/components/Common/Button';
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
import Tag from '@app/components/Common/Tag';
import Tooltip from '@app/components/Common/Tooltip';
import CompanyTag from '@app/components/CompanyTag';
import { sliderTitles } from '@app/components/Discover/constants';
import CreateSlider from '@app/components/Discover/CreateSlider';
import GenreTag from '@app/components/GenreTag';
import KeywordTag from '@app/components/KeywordTag';
import globalMessages from '@app/i18n/globalMessages';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import {
ArrowUturnLeftIcon,
Bars3Icon,
ChevronDownIcon,
ChevronUpIcon,
PencilIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-aria';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
deletesuccess: 'Sucessfully deleted slider.',
deletefail: 'Failed to delete slider.',
remove: 'Remove',
enable: 'Toggle Visibility',
});
const Position = {
None: 'None',
Above: 'Above',
Below: 'Below',
} as const;
type DiscoverSliderEditProps = {
slider: Partial<DiscoverSlider>;
onEnable: () => void;
onDelete: () => void;
onPositionUpdate: (
updatedItemId: number,
position: keyof typeof Position,
isClickable: boolean
) => void;
children: React.ReactNode;
disableUpButton: boolean;
disableDownButton: boolean;
};
const DiscoverSliderEdit = ({
slider,
children,
onEnable,
onDelete,
onPositionUpdate,
disableUpButton,
disableDownButton,
}: DiscoverSliderEditProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [isEditing, setIsEditing] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [hoverPosition, setHoverPosition] = useState<keyof typeof Position>(
Position.None
);
const { dragProps, isDragging } = useDrag({
getItems() {
return [{ id: (slider.id ?? -1).toString(), title: slider.title ?? '' }];
},
});
const deleteSlider = async () => {
try {
await axios.delete(`/api/v1/settings/discover/${slider.id}`);
addToast(intl.formatMessage(messages.deletesuccess), {
appearance: 'success',
autoDismiss: true,
});
onDelete();
} catch (e) {
addToast(intl.formatMessage(messages.deletefail), {
appearance: 'error',
autoDismiss: true,
});
}
};
const { dropProps } = useDrop({
ref,
onDropMove: (e) => {
if (ref.current) {
const middlePoint = ref.current.offsetHeight / 2;
if (e.y < middlePoint) {
setHoverPosition(Position.Above);
} else {
setHoverPosition(Position.Below);
}
}
},
onDropExit: () => {
setHoverPosition(Position.None);
},
onDrop: async (e) => {
const items = await Promise.all(
e.items
.filter((item) => item.kind === 'text' && item.types.has('id'))
.map(async (item) => {
if (item.kind === 'text') {
return item.getText('id');
}
})
);
if (items?.[0]) {
const dropped = Number(items[0]);
onPositionUpdate(dropped, hoverPosition, false);
}
},
});
const getSliderTitle = (slider: Partial<DiscoverSlider>): string => {
switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED:
return intl.formatMessage(sliderTitles.recentlyAdded);
case DiscoverSliderType.RECENT_REQUESTS:
return intl.formatMessage(sliderTitles.recentrequests);
case DiscoverSliderType.PLEX_WATCHLIST:
return intl.formatMessage(sliderTitles.plexwatchlist);
case DiscoverSliderType.TRENDING:
return intl.formatMessage(sliderTitles.trending);
case DiscoverSliderType.POPULAR_MOVIES:
return intl.formatMessage(sliderTitles.popularmovies);
case DiscoverSliderType.MOVIE_GENRES:
return intl.formatMessage(sliderTitles.moviegenres);
case DiscoverSliderType.UPCOMING_MOVIES:
return intl.formatMessage(sliderTitles.upcoming);
case DiscoverSliderType.STUDIOS:
return intl.formatMessage(sliderTitles.studios);
case DiscoverSliderType.POPULAR_TV:
return intl.formatMessage(sliderTitles.populartv);
case DiscoverSliderType.TV_GENRES:
return intl.formatMessage(sliderTitles.tvgenres);
case DiscoverSliderType.UPCOMING_TV:
return intl.formatMessage(sliderTitles.upcomingtv);
case DiscoverSliderType.NETWORKS:
return intl.formatMessage(sliderTitles.networks);
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
return intl.formatMessage(sliderTitles.tmdbmoviekeyword);
case DiscoverSliderType.TMDB_TV_KEYWORD:
return intl.formatMessage(sliderTitles.tmdbtvkeyword);
case DiscoverSliderType.TMDB_MOVIE_GENRE:
return intl.formatMessage(sliderTitles.tmdbmoviegenre);
case DiscoverSliderType.TMDB_TV_GENRE:
return intl.formatMessage(sliderTitles.tmdbtvgenre);
case DiscoverSliderType.TMDB_STUDIO:
return intl.formatMessage(sliderTitles.tmdbstudio);
case DiscoverSliderType.TMDB_NETWORK:
return intl.formatMessage(sliderTitles.tmdbnetwork);
case DiscoverSliderType.TMDB_SEARCH:
return intl.formatMessage(sliderTitles.tmdbsearch);
default:
return 'Unknown Slider';
}
};
return (
<div
key={`discover-slider-${slider.id}-editing`}
data-testid="discover-slider-edit-mode"
className={`relative mb-4 rounded-lg bg-gray-800 shadow-md ${
isDragging ? 'opacity-0' : 'opacity-100'
}`}
{...dragProps}
{...dropProps}
ref={ref}
>
{hoverPosition === Position.Above && (
<div
className={`absolute -top-3 left-0 w-full border-t-4 border-indigo-500`}
/>
)}
{hoverPosition === Position.Below && (
<div
className={`absolute -bottom-2 left-0 w-full border-t-4 border-indigo-500`}
/>
)}
<div className="flex w-full flex-col rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-gray-400 md:flex-row md:items-center md:space-x-2">
<div
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
>
<Bars3Icon className="h-6 w-6" />
<div>{getSliderTitle(slider)}</div>
</div>
<div
className={`pointer-events-none ${
slider.data ? 'mb-4' : ''
} flex-1 md:mb-0`}
>
{(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD ||
slider.type === DiscoverSliderType.TMDB_TV_KEYWORD) && (
<div className="flex space-x-2">
{slider.data?.split(',').map((keywordId) => (
<KeywordTag
key={`slider-keywords-${slider.id}-${keywordId}`}
keywordId={Number(keywordId)}
/>
))}
</div>
)}
{(slider.type === DiscoverSliderType.TMDB_NETWORK ||
slider.type === DiscoverSliderType.TMDB_STUDIO) && (
<CompanyTag
type={
slider.type === DiscoverSliderType.TMDB_STUDIO
? 'studio'
: 'network'
}
companyId={Number(slider.data)}
/>
)}
{(slider.type === DiscoverSliderType.TMDB_TV_GENRE ||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE) && (
<GenreTag
type={
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE
? 'movie'
: 'tv'
}
genreId={Number(slider.data)}
/>
)}
{slider.type === DiscoverSliderType.TMDB_SEARCH && (
<Tag iconSvg={<MagnifyingGlassIcon />}>{slider.data}</Tag>
)}
</div>
<div className="flex items-center space-x-2">
{!slider.isBuiltIn && (
<>
{!isEditing ? (
<Button
buttonType="warning"
buttonSize="sm"
onClick={() => {
setIsEditing(true);
}}
>
<PencilIcon />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</Button>
) : (
<Button
buttonType="default"
buttonSize="sm"
onClick={() => {
setIsEditing(false);
}}
>
<ArrowUturnLeftIcon />
<span>{intl.formatMessage(globalMessages.cancel)}</span>
</Button>
)}
<Button
data-testid="discover-slider-remove-button"
buttonType="danger"
buttonSize="sm"
onClick={() => {
deleteSlider();
}}
>
<XMarkIcon />
<span>{intl.formatMessage(messages.remove)}</span>
</Button>
</>
)}
<div className="absolute right-14 top-4 flex px-2 md:relative md:top-0 md:right-0">
<button
className={'hover:text-white disabled:text-gray-800'}
onClick={() =>
onPositionUpdate(Number(slider.id), Position.Above, true)
}
disabled={disableUpButton}
>
<ChevronUpIcon className="h-7 w-7 md:h-6 md:w-6" />
</button>
<button
className={'hover:text-white disabled:text-gray-800'}
onClick={() =>
onPositionUpdate(Number(slider.id), Position.Below, true)
}
disabled={disableDownButton}
>
<ChevronDownIcon className="h-7 w-7 md:h-6 md:w-6" />
</button>
</div>
<div className="absolute top-4 right-4 flex-1 text-right md:relative md:top-0 md:right-0">
<Tooltip content={intl.formatMessage(messages.enable)}>
<div>
<SlideCheckbox
onClick={() => {
onEnable();
}}
checked={slider.enabled}
/>
</div>
</Tooltip>
</div>
</div>
</div>
{isEditing ? (
<div className="p-4">
<CreateSlider
onCreate={() => {
onDelete();
setIsEditing(false);
}}
slider={slider}
/>
</div>
) : (
<div className={`-mt-6 p-4 ${!slider.enabled ? 'opacity-50' : ''}`}>
{children}
</div>
)}
</div>
);
};
export default DiscoverSliderEdit;

View File

@@ -0,0 +1,145 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { FilterOptions } from '@app/components/Discover/constants';
import {
countActiveFilters,
prepareFilterValues,
} from '@app/components/Discover/constants';
import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovertv: 'Series',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
sortPopularityAsc: 'Popularity Ascending',
sortPopularityDesc: 'Popularity Descending',
sortFirstAirDateAsc: 'First Air Date Ascending',
sortFirstAirDateDesc: 'First Air Date Descending',
sortTmdbRatingAsc: 'TMDB Rating Ascending',
sortTmdbRatingDesc: 'TMDB Rating Descending',
sortTitleAsc: 'Title (A-Z) Ascending',
sortTitleDesc: 'Title (Z-A) Descending',
});
const SortOptions: Record<string, TMDBSortOptions> = {
PopularityAsc: 'popularity.asc',
PopularityDesc: 'popularity.desc',
FirstAirDateAsc: 'first_air_date.asc',
FirstAirDateDesc: 'first_air_date.desc',
TmdbRatingAsc: 'vote_average.asc',
TmdbRatingDesc: 'vote_average.desc',
TitleAsc: 'original_title.asc',
TitleDesc: 'original_title.desc',
} as const;
const DiscoverTv = () => {
const intl = useIntl();
const router = useRouter();
const [showFilters, setShowFilters] = useState(false);
const preparedFilters = prepareFilterValues(router.query);
const updateQueryParams = useUpdateQueryParams({});
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<TvResult, never, FilterOptions>('/api/v1/discover/tv', {
...preparedFilters,
});
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovertv);
return (
<>
<PageTitle title={title} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{title}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sortBy"
name="sortBy"
className="rounded-r-only"
value={preparedFilters.sortBy}
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
>
<option value={SortOptions.PopularityDesc}>
{intl.formatMessage(messages.sortPopularityDesc)}
</option>
<option value={SortOptions.PopularityAsc}>
{intl.formatMessage(messages.sortPopularityAsc)}
</option>
<option value={SortOptions.FirstAirDateDesc}>
{intl.formatMessage(messages.sortFirstAirDateDesc)}
</option>
<option value={SortOptions.FirstAirDateAsc}>
{intl.formatMessage(messages.sortFirstAirDateAsc)}
</option>
<option value={SortOptions.TmdbRatingDesc}>
{intl.formatMessage(messages.sortTmdbRatingDesc)}
</option>
<option value={SortOptions.TmdbRatingAsc}>
{intl.formatMessage(messages.sortTmdbRatingAsc)}
</option>
<option value={SortOptions.TitleAsc}>
{intl.formatMessage(messages.sortTitleAsc)}
</option>
<option value={SortOptions.TitleDesc}>
{intl.formatMessage(messages.sortTitleDesc)}
</option>
</select>
</div>
<FilterSlideover
type="tv"
currentFilters={preparedFilters}
onClose={() => setShowFilters(false)}
show={showFilters}
/>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<Button onClick={() => setShowFilters(true)} className="w-full">
<FunnelIcon />
<span>
{intl.formatMessage(messages.activefilters, {
count: countActiveFilters(preparedFilters),
})}
</span>
</Button>
</div>
</div>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTv;

View File

@@ -1,16 +1,20 @@
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovertv: 'Popular Series',
keywordSeries: '{keywordTitle} Series',
});
const DiscoverTv = () => {
const DiscoverTvKeyword = () => {
const router = useRouter();
const intl = useIntl();
const {
@@ -21,13 +25,25 @@ const DiscoverTv = () => {
titles,
fetchMore,
error,
} = useDiscover<TvResult>('/api/v1/discover/tv');
firstResultData,
} = useDiscover<TvResult, { keywords: TmdbKeyword[] }>(
`/api/v1/discover/tv`,
{
keywords: encodeURIExtraParams(router.query.keywords as string),
}
);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovertv);
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.keywordSeries, {
keywordTitle: firstResultData?.keywords
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
.join(', '),
});
return (
<>
@@ -38,14 +54,14 @@ const DiscoverTv = () => {
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTv;
export default DiscoverTvKeyword;

View File

@@ -0,0 +1,297 @@
import Button from '@app/components/Common/Button';
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
import SlideOver from '@app/components/Common/SlideOver';
import type { FilterOptions } from '@app/components/Discover/constants';
import { countActiveFilters } from '@app/components/Discover/constants';
import LanguageSelector from '@app/components/LanguageSelector';
import {
CompanySelector,
GenreSelector,
KeywordSelector,
WatchProviderSelector,
} from '@app/components/Selector';
import useSettings from '@app/hooks/useSettings';
import {
useBatchUpdateQueryParams,
useUpdateQueryParams,
} from '@app/hooks/useUpdateQueryParams';
import { XCircleIcon } from '@heroicons/react/24/outline';
import { defineMessages, useIntl } from 'react-intl';
import Datepicker from 'react-tailwindcss-datepicker-sct';
const messages = defineMessages({
filters: 'Filters',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
releaseDate: 'Release Date',
firstAirDate: 'First Air Date',
from: 'From',
to: 'To',
studio: 'Studio',
genres: 'Genres',
keywords: 'Keywords',
originalLanguage: 'Original Language',
runtimeText: '{minValue}-{maxValue} minute runtime',
ratingText: 'Ratings between {minValue} and {maxValue}',
clearfilters: 'Clear Active Filters',
tmdbuserscore: 'TMDB User Score',
runtime: 'Runtime',
streamingservices: 'Streaming Services',
});
type FilterSlideoverProps = {
show: boolean;
onClose: () => void;
type: 'movie' | 'tv';
currentFilters: FilterOptions;
};
const FilterSlideover = ({
show,
onClose,
type,
currentFilters,
}: FilterSlideoverProps) => {
const intl = useIntl();
const { currentSettings } = useSettings();
const updateQueryParams = useUpdateQueryParams({});
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
const dateGte =
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
const dateLte =
type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte';
return (
<SlideOver
show={show}
title={intl.formatMessage(messages.filters)}
subText={intl.formatMessage(messages.activefilters, {
count: countActiveFilters(currentFilters),
})}
onClose={() => onClose()}
>
<div className="flex flex-col space-y-4">
<div>
<div className="mb-2 text-lg font-semibold">
{intl.formatMessage(
type === 'movie' ? messages.releaseDate : messages.firstAirDate
)}
</div>
<div className="relative z-40 flex space-x-2">
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateGte] ?? null,
endDate: currentFilters[dateGte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateGte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="fromdate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
</div>
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateLte] ?? null,
endDate: currentFilters[dateLte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateLte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="todate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
</div>
</div>
</div>
{type === 'movie' && (
<>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.studio)}
</span>
<CompanySelector
defaultValue={currentFilters.studio}
onChange={(value) => {
updateQueryParams('studio', value?.value.toString());
}}
/>
</>
)}
<span className="text-lg font-semibold">
{intl.formatMessage(messages.genres)}
</span>
<GenreSelector
type={type}
defaultValue={currentFilters.genre}
isMulti
onChange={(value) => {
updateQueryParams('genre', value?.map((v) => v.value).join(','));
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.keywords)}
</span>
<KeywordSelector
defaultValue={currentFilters.keywords}
isMulti
onChange={(value) => {
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.originalLanguage)}
</span>
<LanguageSelector
value={currentFilters.language}
serverValue={currentSettings.originalLanguage}
isUserSettings
setFieldValue={(_key, value) => {
updateQueryParams('language', value);
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.runtime)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={400}
onUpdateMin={(min) => {
updateQueryParams(
'withRuntimeGte',
min !== 0 && Number(currentFilters.withRuntimeLte) !== 400
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'withRuntimeLte',
max !== 400 && Number(currentFilters.withRuntimeGte) !== 0
? max.toString()
: undefined
);
}}
defaultMaxValue={
currentFilters.withRuntimeLte
? Number(currentFilters.withRuntimeLte)
: undefined
}
defaultMinValue={
currentFilters.withRuntimeGte
? Number(currentFilters.withRuntimeGte)
: undefined
}
subText={intl.formatMessage(messages.runtimeText, {
minValue: currentFilters.withRuntimeGte ?? 0,
maxValue: currentFilters.withRuntimeLte ?? 400,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuserscore)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={1}
max={10}
defaultMaxValue={
currentFilters.voteAverageLte
? Number(currentFilters.voteAverageLte)
: undefined
}
defaultMinValue={
currentFilters.voteAverageGte
? Number(currentFilters.voteAverageGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteAverageGte',
min !== 1 && Number(currentFilters.voteAverageLte) !== 10
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteAverageLte',
max !== 10 && Number(currentFilters.voteAverageGte) !== 1
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.ratingText, {
minValue: currentFilters.voteAverageGte ?? 1,
maxValue: currentFilters.voteAverageLte ?? 10,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)}
</span>
<WatchProviderSelector
type={type}
region={currentFilters.watchRegion}
activeProviders={
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
[]
}
onChange={(region, providers) => {
if (providers.length) {
batchUpdateQueryParams({
watchRegion: region,
watchProviders: providers.join('|'),
});
} else {
batchUpdateQueryParams({
watchRegion: undefined,
watchProviders: undefined,
});
}
}}
/>
<div className="pt-4">
<Button
className="w-full"
disabled={Object.keys(currentFilters).length === 0}
onClick={() => {
const copyCurrent = Object.assign({}, currentFilters);
(
Object.keys(copyCurrent) as (keyof typeof currentFilters)[]
).forEach((k) => {
copyCurrent[k] = undefined;
});
batchUpdateQueryParams(copyCurrent);
onClose();
}}
>
<XCircleIcon />
<span>{intl.formatMessage(messages.clearfilters)}</span>
</Button>
</div>
</div>
</SlideOver>
);
};
export default FilterSlideover;

View File

@@ -1,7 +1,7 @@
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import React from 'react';
@@ -28,7 +28,7 @@ const MovieGenreSlider = () => {
<Link href="/discover/movies/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.moviegenres)}</span>
<ArrowCircleRightIcon />
<ArrowRightCircleIcon />
</a>
</Link>
</div>
@@ -43,7 +43,7 @@ const MovieGenreSlider = () => {
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/movies/genre/${genre.id}`}
url={`/discover/movies?genre=${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}

View File

@@ -0,0 +1,79 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { UserType, useUser } from '@app/hooks/useUser';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
plexwatchlist: 'Your Plex Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
});
const PlexWatchlistSlider = () => {
const intl = useIntl();
const { user } = useUser();
const { data: watchlistItems, error: watchlistError } = useSWR<{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
revalidateOnMount: true,
});
if (
user?.userType !== UserType.PLEX ||
(watchlistItems &&
watchlistItems.results.length === 0 &&
!user?.settings?.watchlistSyncMovies &&
!user?.settings?.watchlistSyncTv) ||
watchlistError
) {
return null;
}
return (
<>
<div className="slider-header">
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowRightCircleIcon />
</a>
</Link>
</div>
<Slider
sliderKey="watchlist"
isLoading={!watchlistItems}
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
})}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
/>
))}
/>
</>
);
};
export default PlexWatchlistSlider;

View File

@@ -0,0 +1,49 @@
import { sliderTitles } from '@app/components/Discover/constants';
import RequestCard from '@app/components/RequestCard';
import Slider from '@app/components/Slider';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
const RecentRequestsSlider = () => {
const intl = useIntl();
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{
revalidateOnMount: true,
}
);
if (requests && requests.results.length === 0 && !requestError) {
return null;
}
return (
<>
<div className="slider-header">
<Link href="/requests?filter=all">
<a className="slider-title">
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
<ArrowRightCircleIcon />
</a>
</Link>
</div>
<Slider
sliderKey="requests"
isLoading={!requests}
items={(requests?.results ?? []).map((request) => (
<RequestCard
key={`request-slider-item-${request.id}`}
request={request}
/>
))}
placeholder={<RequestCard.Placeholder />}
/>
</>
);
};
export default RecentRequestsSlider;

View File

@@ -0,0 +1,53 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
recentlyAdded: 'Recently Added',
});
const RecentlyAddedSlider = () => {
const intl = useIntl();
const { hasPermission } = useUser();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
);
if (
(media && !media.results.length && !mediaError) ||
!hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
type: 'or',
})
) {
return null;
}
return (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={(media?.results ?? []).map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tvdbId={item.tvdbId}
type={item.mediaType}
/>
))}
/>
</>
);
};
export default RecentlyAddedSlider;

View File

@@ -1,7 +1,7 @@
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import React from 'react';
@@ -28,7 +28,7 @@ const TvGenreSlider = () => {
<Link href="/discover/tv/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.tvgenres)}</span>
<ArrowCircleRightIcon />
<ArrowRightCircleIcon />
</a>
</Link>
</div>
@@ -43,7 +43,7 @@ const TvGenreSlider = () => {
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/tv/genre/${genre.id}`}
url={`/discover/tv?genre=${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}

View File

@@ -1,3 +1,7 @@
import type { ParsedUrlQuery } from 'querystring';
import { defineMessages } from 'react-intl';
import { z } from 'zod';
type AvailableColors =
| 'black'
| 'red'
@@ -61,3 +65,142 @@ export const genreColorMap: Record<number, [string, string]> = {
10767: colorTones.lightgreen, // Talk
10768: colorTones.darkred, // War & Politics
};
export const sliderTitles = defineMessages({
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
moviegenres: 'Movie Genres',
tvgenres: 'Series Genres',
studios: 'Studios',
networks: 'Networks',
tmdbmoviekeyword: 'TMDB Movie Keyword',
tmdbtvkeyword: 'TMDB Series Keyword',
tmdbmoviegenre: 'TMDB Movie Genre',
tmdbtvgenre: 'TMDB Series Genre',
tmdbnetwork: 'TMDB Network',
tmdbstudio: 'TMDB Studio',
tmdbsearch: 'TMDB Search',
});
export const QueryFilterOptions = z.object({
sortBy: z.string().optional(),
primaryReleaseDateGte: z.string().optional(),
primaryReleaseDateLte: z.string().optional(),
firstAirDateGte: z.string().optional(),
firstAirDateLte: z.string().optional(),
studio: z.string().optional(),
genre: z.string().optional(),
keywords: z.string().optional(),
language: z.string().optional(),
withRuntimeGte: z.string().optional(),
withRuntimeLte: z.string().optional(),
voteAverageGte: z.string().optional(),
voteAverageLte: z.string().optional(),
watchRegion: z.string().optional(),
watchProviders: z.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
export const prepareFilterValues = (
inputValues: ParsedUrlQuery
): FilterOptions => {
const filterValues: FilterOptions = {};
const values = QueryFilterOptions.parse(inputValues);
if (values.sortBy) {
filterValues.sortBy = values.sortBy;
}
if (values.primaryReleaseDateGte) {
filterValues.primaryReleaseDateGte = values.primaryReleaseDateGte;
}
if (values.primaryReleaseDateLte) {
filterValues.primaryReleaseDateLte = values.primaryReleaseDateLte;
}
if (values.firstAirDateGte) {
filterValues.firstAirDateGte = values.firstAirDateGte;
}
if (values.firstAirDateLte) {
filterValues.firstAirDateLte = values.firstAirDateLte;
}
if (values.studio) {
filterValues.studio = values.studio;
}
if (values.genre) {
filterValues.genre = values.genre;
}
if (values.keywords) {
filterValues.keywords = values.keywords;
}
if (values.language) {
filterValues.language = values.language;
}
if (values.withRuntimeGte) {
filterValues.withRuntimeGte = values.withRuntimeGte;
}
if (values.withRuntimeLte) {
filterValues.withRuntimeLte = values.withRuntimeLte;
}
if (values.voteAverageGte) {
filterValues.voteAverageGte = values.voteAverageGte;
}
if (values.voteAverageLte) {
filterValues.voteAverageLte = values.voteAverageLte;
}
if (values.watchProviders) {
filterValues.watchProviders = values.watchProviders;
}
if (values.watchRegion) {
filterValues.watchRegion = values.watchRegion;
}
return filterValues;
};
export const countActiveFilters = (filterValues: FilterOptions): number => {
let totalCount = 0;
const clonedFilters = Object.assign({}, filterValues);
if (clonedFilters.voteAverageGte || filterValues.voteAverageLte) {
totalCount += 1;
delete clonedFilters.voteAverageGte;
delete clonedFilters.voteAverageLte;
}
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
totalCount += 1;
delete clonedFilters.withRuntimeGte;
delete clonedFilters.withRuntimeLte;
}
if (clonedFilters.watchProviders) {
totalCount += 1;
delete clonedFilters.watchProviders;
delete clonedFilters.watchRegion;
}
totalCount += Object.keys(clonedFilters).length;
return totalCount;
};

View File

@@ -1,189 +1,430 @@
import Button from '@app/components/Common/Button';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import CreateSlider from '@app/components/Discover/CreateSlider';
import DiscoverSliderEdit from '@app/components/Discover/DiscoverSliderEdit';
import MovieGenreSlider from '@app/components/Discover/MovieGenreSlider';
import NetworkSlider from '@app/components/Discover/NetworkSlider';
import PlexWatchlistSlider from '@app/components/Discover/PlexWatchlistSlider';
import RecentlyAddedSlider from '@app/components/Discover/RecentlyAddedSlider';
import RecentRequestsSlider from '@app/components/Discover/RecentRequestsSlider';
import StudioSlider from '@app/components/Discover/StudioSlider';
import TvGenreSlider from '@app/components/Discover/TvGenreSlider';
import MediaSlider from '@app/components/MediaSlider';
import RequestCard from '@app/components/RequestCard';
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import {
ArrowDownOnSquareIcon,
ArrowPathIcon,
ArrowUturnLeftIcon,
PencilIcon,
PlusIcon,
} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages({
discover: 'Discover',
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
resettodefault: 'Reset to Default',
resetwarning:
'Reset all sliders to default. This will also delete any custom sliders!',
updatesuccess: 'Updated discover customization settings.',
updatefailed:
'Something went wrong updating the discover customization settings.',
resetsuccess: 'Sucessfully reset discover customization settings.',
resetfailed:
'Something went wrong resetting the discover customization settings.',
customizediscover: 'Customize Discover',
stopediting: 'Stop Editing',
createnewslider: 'Create New Slider',
});
const Discover = () => {
const intl = useIntl();
const { user, hasPermission } = useUser();
const { hasPermission } = useUser();
const { addToast } = useToasts();
const {
data: discoverData,
error: discoverError,
mutate,
} = useSWR<DiscoverSlider[]>('/api/v1/settings/discover');
const [sliders, setSliders] = useState<Partial<DiscoverSlider>[]>([]);
const [isEditing, setIsEditing] = useState(false);
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
);
// We need to sync the state here so that we can modify the changes locally without commiting
// anything to the server until the user decides to save the changes
useEffect(() => {
if (discoverData && !isEditing) {
setSliders(discoverData);
}
}, [discoverData, isEditing]);
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{
revalidateOnMount: true,
}
);
const hasChanged = () => !Object.is(discoverData, sliders);
const { data: watchlistItems, error: watchlistError } = useSWR<{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
revalidateOnMount: true,
});
const updateSliders = async () => {
try {
await axios.post('/api/v1/settings/discover', sliders);
addToast(intl.formatMessage(messages.updatesuccess), {
appearance: 'success',
autoDismiss: true,
});
setIsEditing(false);
mutate();
} catch (e) {
addToast(intl.formatMessage(messages.updatefailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const resetSliders = async () => {
try {
await axios.get('/api/v1/settings/discover/reset');
addToast(intl.formatMessage(messages.resetsuccess), {
appearance: 'success',
autoDismiss: true,
});
setIsEditing(false);
mutate();
} catch (e) {
addToast(intl.formatMessage(messages.resetfailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const now = new Date();
const offset = now.getTimezoneOffset();
const upcomingDate = new Date(now.getTime() - offset * 60 * 1000)
.toISOString()
.split('T')[0];
if (!discoverData && !discoverError) {
return <LoadingSpinner />;
}
return (
<>
<PageTitle title={intl.formatMessage(messages.discover)} />
{(!media || !!media.results.length) &&
!mediaError &&
hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
type: 'or',
}) && (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
{hasPermission(Permission.ADMIN) && (
<>
{isEditing && (
<div className="my-6 rounded-lg bg-gray-800">
<div className="flex items-center space-x-2 rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-lg font-semibold text-gray-400">
<PlusIcon className="w-6" />
<span data-testid="create-slider-header">
{intl.formatMessage(messages.createnewslider)}
</span>
</div>
<div className="p-4">
<CreateSlider
onCreate={async () => {
const newSliders = await mutate();
if (newSliders) {
setSliders(newSliders);
}
}}
/>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={(media?.results ?? []).map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tvdbId={item.tvdbId}
type={item.mediaType}
/>
))}
/>
</>
)}
{(!requests || !!requests.results.length) && !requestError && (
<>
<div className="slider-header">
<Link href="/requests?filter=all">
<a className="slider-title">
<span>{intl.formatMessage(messages.recentrequests)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="requests"
isLoading={!requests}
items={(requests?.results ?? []).map((request) => (
<RequestCard
key={`request-slider-item-${request.id}`}
request={request}
/>
))}
placeholder={<RequestCard.Placeholder />}
/>
)}
<Transition
show={!isEditing}
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute-bottom-shift fixed right-6 z-50 flex items-center sm:bottom-8"
>
<button
onClick={() => setIsEditing(true)}
data-testid="discover-start-editing"
className="h-12 w-12 rounded-full border-2 border-gray-600 bg-gray-700 bg-opacity-90 p-3 text-gray-400 shadow transition-all hover:bg-opacity-100"
>
<PencilIcon className="h-full w-full" />
</button>
</Transition>
<Transition
show={isEditing}
enter="transition transform duration-300"
enterFrom="opacity-0 translate-y-6"
enterTo="opacity-100 translate-y-0"
leave="transition duration-300 transform"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-6"
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
>
<Button
buttonType="default"
onClick={() => setIsEditing(false)}
className="w-full sm:w-auto"
>
<ArrowUturnLeftIcon />
<span>{intl.formatMessage(messages.stopediting)}</span>
</Button>
<Tooltip content={intl.formatMessage(messages.resetwarning)}>
<ConfirmButton
onClick={() => resetSliders()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full sm:w-auto"
>
<ArrowPathIcon />
<span>{intl.formatMessage(messages.resettodefault)}</span>
</ConfirmButton>
</Tooltip>
<Button
buttonType="primary"
type="submit"
disabled={!hasChanged()}
onClick={() => updateSliders()}
data-testid="discover-customize-submit"
className="w-full sm:w-auto"
>
<ArrowDownOnSquareIcon />
<span>{intl.formatMessage(globalMessages.save)}</span>
</Button>
</Transition>
</>
)}
{user?.userType === UserType.PLEX &&
(!watchlistItems ||
!!watchlistItems.results.length ||
user.settings?.watchlistSyncMovies ||
user.settings?.watchlistSyncTv) &&
!watchlistError && (
<>
<div className="slider-header">
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="watchlist"
isLoading={!watchlistItems}
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
})}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
/>
))}
/>
</>
)}
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(messages.trending)}
url="/api/v1/discover/trending"
linkUrl="/discover/trending"
/>
<MediaSlider
sliderKey="popular-movies"
title={intl.formatMessage(messages.popularmovies)}
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
<MovieGenreSlider />
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(messages.upcoming)}
linkUrl="/discover/movies/upcoming"
url="/api/v1/discover/movies/upcoming"
/>
<StudioSlider />
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(messages.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
<TvGenreSlider />
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(messages.upcomingtv)}
url="/api/v1/discover/tv/upcoming"
linkUrl="/discover/tv/upcoming"
/>
<NetworkSlider />
{(isEditing ? sliders : discoverData)?.map((slider, index) => {
let sliderComponent: React.ReactNode;
switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED:
sliderComponent = <RecentlyAddedSlider />;
break;
case DiscoverSliderType.RECENT_REQUESTS:
sliderComponent = <RecentRequestsSlider />;
break;
case DiscoverSliderType.PLEX_WATCHLIST:
sliderComponent = <PlexWatchlistSlider />;
break;
case DiscoverSliderType.TRENDING:
sliderComponent = (
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(sliderTitles.trending)}
url="/api/v1/discover/trending"
linkUrl="/discover/trending"
/>
);
break;
case DiscoverSliderType.POPULAR_MOVIES:
sliderComponent = (
<MediaSlider
sliderKey="popular-movies"
title={intl.formatMessage(sliderTitles.popularmovies)}
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
);
break;
case DiscoverSliderType.MOVIE_GENRES:
sliderComponent = <MovieGenreSlider />;
break;
case DiscoverSliderType.UPCOMING_MOVIES:
sliderComponent = (
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(sliderTitles.upcoming)}
linkUrl={`/discover/movies?primaryReleaseDateGte=${upcomingDate}`}
url="/api/v1/discover/movies"
extraParams={`primaryReleaseDateGte=${upcomingDate}`}
/>
);
break;
case DiscoverSliderType.STUDIOS:
sliderComponent = <StudioSlider />;
break;
case DiscoverSliderType.POPULAR_TV:
sliderComponent = (
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(sliderTitles.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
);
break;
case DiscoverSliderType.TV_GENRES:
sliderComponent = <TvGenreSlider />;
break;
case DiscoverSliderType.UPCOMING_TV:
sliderComponent = (
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(sliderTitles.upcomingtv)}
linkUrl={`/discover/tv?firstAirDateGte=${upcomingDate}`}
url="/api/v1/discover/tv"
extraParams={`firstAirDateGte=${upcomingDate}`}
/>
);
break;
case DiscoverSliderType.NETWORKS:
sliderComponent = <NetworkSlider />;
break;
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/movies"
extraParams={
slider.data
? `keywords=${encodeURIExtraParams(slider.data)}`
: ''
}
linkUrl={`/discover/movies?keywords=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_TV_KEYWORD:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/tv"
extraParams={
slider.data
? `keywords=${encodeURIExtraParams(slider.data)}`
: ''
}
linkUrl={`/discover/tv?keywords=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/movies`}
extraParams={`genre=${slider.data}`}
linkUrl={`/discover/movies?genre=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_TV_GENRE:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/tv`}
extraParams={`genre=${slider.data}`}
linkUrl={`/discover/tv?genre=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_STUDIO:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/movies/studio/${slider.data}`}
linkUrl={`/discover/movies/studio/${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_NETWORK:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/tv/network/${slider.data}`}
linkUrl={`/discover/tv/network/${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_SEARCH:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/search"
extraParams={`query=${slider.data}`}
linkUrl={`/search?query=${slider.data}`}
/>
);
break;
}
if (isEditing) {
return (
<DiscoverSliderEdit
key={`discover-slider-${slider.id}-edit`}
slider={slider}
onDelete={async () => {
const newSliders = await mutate();
if (newSliders) {
setSliders(newSliders);
}
}}
onEnable={() => {
const tempSliders = sliders.slice();
tempSliders[index].enabled = !tempSliders[index].enabled;
setSliders(tempSliders);
}}
onPositionUpdate={(updatedItemId, position, hasClickedArrows) => {
const originalPosition = sliders.findIndex(
(item) => item.id === updatedItemId
);
const originalItem = sliders[originalPosition];
const tempSliders = sliders.slice();
tempSliders.splice(originalPosition, 1);
hasClickedArrows
? tempSliders.splice(
position === 'Above' ? index - 1 : index + 1,
0,
originalItem
)
: tempSliders.splice(
position === 'Above' && index > originalPosition
? Math.max(index - 1, 0)
: index,
0,
originalItem
);
setSliders(tempSliders);
}}
disableUpButton={index === 0}
disableDownButton={index === sliders.length - 1}
>
{sliderComponent}
</DiscoverSliderEdit>
);
}
if (!slider.enabled) {
return null;
}
return (
<div key={`discover-slider-${slider.id}`}>{sliderComponent}</div>
);
})}
</>
);
};

View File

@@ -1,23 +1,39 @@
import Badge from '@app/components/Common/Badge';
import { Permission, useUser } from '@app/hooks/useUser';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
const messages = defineMessages({
estimatedtime: 'Estimated {time}',
formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}',
});
interface DownloadBlockProps {
downloadItem: DownloadingItem;
is4k?: boolean;
title?: string;
}
const DownloadBlock = ({ downloadItem, is4k = false }: DownloadBlockProps) => {
const DownloadBlock = ({
downloadItem,
is4k = false,
title,
}: DownloadBlockProps) => {
const intl = useIntl();
const { hasPermission } = useUser();
return (
<div className="p-4">
<div className="mb-2 w-56 truncate text-sm sm:w-80 md:w-full">
{downloadItem.title}
{hasPermission(Permission.ADMIN)
? downloadItem.title
: downloadItem.episode
? intl.formatMessage(messages.formattedTitle, {
title,
seasonNumber: downloadItem?.episode?.seasonNumber,
episodeNumber: downloadItem?.episode?.episodeNumber,
})
: title}
</div>
<div className="relative mb-2 h-6 min-w-0 overflow-hidden rounded-full bg-gray-700">
<div

View File

@@ -0,0 +1,28 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { RectangleStackIcon } from '@heroicons/react/24/outline';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import useSWR from 'swr';
type GenreTagProps = {
type: 'tv' | 'movie';
genreId: number;
};
const GenreTag = ({ genreId, type }: GenreTagProps) => {
const { data, error } = useSWR<TmdbGenre[]>(`/api/v1/genres/${type}`);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
const genre = data?.find((genre) => genre.id === genreId);
return <Tag iconSvg={<RectangleStackIcon />}>{genre?.name}</Tag>;
};
export default GenreTag;

View File

@@ -3,10 +3,10 @@ import { issueOptions } from '@app/components/IssueModal/constants';
import { useUser } from '@app/hooks/useUser';
import {
CalendarIcon,
ExclamationIcon,
ExclamationTriangleIcon,
EyeIcon,
UserIcon,
} from '@heroicons/react/solid';
} from '@heroicons/react/24/solid';
import type Issue from '@server/entity/Issue';
import Link from 'next/link';
import { useIntl } from 'react-intl';
@@ -31,7 +31,7 @@ const IssueBlock = ({ issue }: IssueBlockProps) => {
<div className="flex items-center justify-between">
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="flex flex-nowrap">
<ExclamationIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
<ExclamationTriangleIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
<span className="w-40 truncate md:w-auto">
{intl.formatMessage(issueOption.name)}
</span>

View File

@@ -2,7 +2,7 @@ import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
import { Permission, useUser } from '@app/hooks/useUser';
import { Menu, Transition } from '@headlessui/react';
import { DotsVerticalIcon } from '@heroicons/react/solid';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
@@ -104,7 +104,7 @@ const IssueComment = ({
<div>
<Menu.Button className="flex items-center rounded-full text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
<span className="sr-only">Open options</span>
<DotsVerticalIcon
<EllipsisVerticalIcon
className="h-5 w-5"
aria-hidden="true"
/>

View File

@@ -2,7 +2,7 @@ import Button from '@app/components/Common/Button';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { Menu, Transition } from '@headlessui/react';
import { DotsVerticalIcon } from '@heroicons/react/solid';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@@ -46,7 +46,10 @@ const IssueDescription = ({
<div>
<Menu.Button className="flex items-center rounded-full text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
<span className="sr-only">Open options</span>
<DotsVerticalIcon className="h-5 w-5" aria-hidden="true" />
<EllipsisVerticalIcon
className="h-5 w-5"
aria-hidden="true"
/>
</Menu.Button>
</div>

View File

@@ -7,18 +7,19 @@ import PageTitle from '@app/components/Common/PageTitle';
import IssueComment from '@app/components/IssueDetails/IssueComment';
import IssueDescription from '@app/components/IssueDetails/IssueDescription';
import { issueOptions } from '@app/components/IssueModal/constants';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { Transition } from '@headlessui/react';
import {
ChatIcon,
ChatBubbleOvalLeftEllipsisIcon,
CheckCircleIcon,
PlayIcon,
ServerIcon,
} from '@heroicons/react/outline';
import { RefreshIcon } from '@heroicons/react/solid';
} from '@heroicons/react/24/outline';
import { ArrowPathIcon } from '@heroicons/react/24/solid';
import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
@@ -91,6 +92,13 @@ const IssueDetails = () => {
: null
);
const { mediaUrl, mediaUrl4k } = useDeepLinks({
mediaUrl: data?.mediaInfo?.mediaUrl,
mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k,
});
const CommentSchema = Yup.object().shape({
message: Yup.string().required(),
});
@@ -359,7 +367,7 @@ const IssueDetails = () => {
{issueData?.media.mediaUrl && (
<Button
as="a"
href={issueData?.media.mediaUrl}
href={mediaUrl}
target="_blank"
rel="noreferrer"
className="w-full"
@@ -382,30 +390,31 @@ const IssueDetails = () => {
</span>
</Button>
)}
{issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openinarr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl &&
hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openinarr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
{issueData?.media.mediaUrl4k && (
<Button
as="a"
href={issueData?.media.mediaUrl4k}
href={mediaUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
@@ -497,7 +506,8 @@ const IssueDetails = () => {
className="h-20"
/>
<div className="mt-4 flex items-center justify-end space-x-2">
{hasPermission(Permission.MANAGE_ISSUES) && (
{(hasPermission(Permission.MANAGE_ISSUES) ||
belongsToUser) && (
<>
{issueData.status === IssueStatus.OPEN ? (
<Button
@@ -532,7 +542,7 @@ const IssueDetails = () => {
}
}}
>
<RefreshIcon />
<ArrowPathIcon />
<span>
{intl.formatMessage(
values.message
@@ -551,7 +561,7 @@ const IssueDetails = () => {
!isValid || isSubmitting || !values.message
}
>
<ChatIcon />
<ChatBubbleOvalLeftEllipsisIcon />
<span>
{intl.formatMessage(messages.leavecomment)}
</span>
@@ -621,7 +631,7 @@ const IssueDetails = () => {
{issueData?.media.mediaUrl && (
<Button
as="a"
href={issueData?.media.mediaUrl}
href={mediaUrl}
target="_blank"
rel="noreferrer"
className="w-full"
@@ -667,7 +677,7 @@ const IssueDetails = () => {
{issueData?.media.mediaUrl4k && (
<Button
as="a"
href={issueData?.media.mediaUrl4k}
href={mediaUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
@@ -690,29 +700,31 @@ const IssueDetails = () => {
</span>
</Button>
)}
{issueData?.media.serviceUrl4k && hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openin4karr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl4k &&
hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openin4karr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
</div>
</div>
</div>
<div className="extra-bottom-space" />
</div>
);
};

View File

@@ -4,7 +4,7 @@ import CachedImage from '@app/components/Common/CachedImage';
import { issueOptions } from '@app/components/IssueModal/constants';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { EyeIcon } from '@heroicons/react/solid';
import { EyeIcon } from '@heroicons/react/24/solid';
import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import type Issue from '@server/entity/Issue';

View File

@@ -6,11 +6,11 @@ import IssueItem from '@app/components/IssueList/IssueItem';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import globalMessages from '@app/i18n/globalMessages';
import {
BarsArrowDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
FilterIcon,
SortDescendingIcon,
} from '@heroicons/react/solid';
FunnelIcon,
} from '@heroicons/react/24/solid';
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -98,7 +98,7 @@ const IssueList = () => {
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<FilterIcon className="h-6 w-6" />
<FunnelIcon className="h-6 w-6" />
</span>
<select
id="filter"
@@ -128,7 +128,7 @@ const IssueList = () => {
</div>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<SortDescendingIcon className="h-6 w-6" />
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sort"

View File

@@ -5,7 +5,7 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { RadioGroup } from '@headlessui/react';
import { ArrowCircleRightIcon } from '@heroicons/react/solid';
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
@@ -121,7 +121,7 @@ const CreateIssueModal = ({
<Link href={`/issues/${newIssue.data.id}`}>
<Button as="a" className="mt-4">
<span>{intl.formatMessage(messages.toastviewissue)}</span>
<ArrowCircleRightIcon />
<ArrowRightCircleIcon />
</Button>
</Link>
</>,

View File

@@ -0,0 +1,24 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import type { Keyword } from '@server/models/common';
import useSWR from 'swr';
type KeywordTagProps = {
keywordId: number;
};
const KeywordTag = ({ keywordId }: KeywordTagProps) => {
const { data, error } = useSWR<Keyword>(`/api/v1/keyword/${keywordId}`);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
return <Tag>{data?.name}</Tag>;
};
export default KeywordTag;

View File

@@ -3,7 +3,7 @@ import { availableLanguages } from '@app/context/LanguageContext';
import useClickOutside from '@app/hooks/useClickOutside';
import useLocale from '@app/hooks/useLocale';
import { Transition } from '@headlessui/react';
import { TranslateIcon } from '@heroicons/react/solid';
import { LanguageIcon } from '@heroicons/react/24/solid';
import { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@@ -28,7 +28,7 @@ const LanguagePicker = () => {
aria-label="Language Picker"
onClick={() => setDropdownOpen(true)}
>
<TranslateIcon className="h-6 w-6" />
<LanguageIcon className="h-6 w-6" />
</button>
</div>
<Transition

View File

@@ -0,0 +1,210 @@
import { menuMessages } from '@app/components/Layout/Sidebar';
import useClickOutside from '@app/hooks/useClickOutside';
import { Permission, useUser } from '@app/hooks/useUser';
import { Transition } from '@headlessui/react';
import {
ClockIcon,
CogIcon,
EllipsisHorizontalIcon,
ExclamationTriangleIcon,
FilmIcon,
SparklesIcon,
TvIcon,
UsersIcon,
} from '@heroicons/react/24/outline';
import {
ClockIcon as FilledClockIcon,
CogIcon as FilledCogIcon,
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
FilmIcon as FilledFilmIcon,
SparklesIcon as FilledSparklesIcon,
TvIcon as FilledTvIcon,
UsersIcon as FilledUsersIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { cloneElement, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
interface MenuLink {
href: string;
svgIcon: JSX.Element;
svgIconSelected: JSX.Element;
content: React.ReactNode;
activeRegExp: RegExp;
as?: string;
requiredPermission?: Permission | Permission[];
permissionType?: 'and' | 'or';
dataTestId?: string;
}
const MobileMenu = () => {
const ref = useRef<HTMLDivElement>(null);
const intl = useIntl();
const [isOpen, setIsOpen] = useState(false);
const { hasPermission } = useUser();
const router = useRouter();
useClickOutside(ref, () => {
setTimeout(() => {
if (isOpen) {
setIsOpen(false);
}
}, 150);
});
const toggle = () => setIsOpen(!isOpen);
const menuLinks: MenuLink[] = [
{
href: '/',
content: intl.formatMessage(menuMessages.dashboard),
svgIcon: <SparklesIcon className="h-6 w-6" />,
svgIconSelected: <FilledSparklesIcon className="h-6 w-6" />,
activeRegExp: /^\/(discover\/?)?$/,
},
{
href: '/discover/movies',
content: intl.formatMessage(menuMessages.browsemovies),
svgIcon: <FilmIcon className="h-6 w-6" />,
svgIconSelected: <FilledFilmIcon className="h-6 w-6" />,
activeRegExp: /^\/discover\/movies$/,
},
{
href: '/discover/tv',
content: intl.formatMessage(menuMessages.browsetv),
svgIcon: <TvIcon className="h-6 w-6" />,
svgIconSelected: <FilledTvIcon className="h-6 w-6" />,
activeRegExp: /^\/discover\/tv$/,
},
{
href: '/requests',
content: intl.formatMessage(menuMessages.requests),
svgIcon: <ClockIcon className="h-6 w-6" />,
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
activeRegExp: /^\/requests/,
},
{
href: '/issues',
content: intl.formatMessage(menuMessages.issues),
svgIcon: <ExclamationTriangleIcon className="h-6 w-6" />,
svgIconSelected: <FilledExclamationTriangleIcon className="h-6 w-6" />,
activeRegExp: /^\/issues/,
requiredPermission: [
Permission.MANAGE_ISSUES,
Permission.CREATE_ISSUES,
Permission.VIEW_ISSUES,
],
permissionType: 'or',
},
{
href: '/users',
content: intl.formatMessage(menuMessages.users),
svgIcon: <UsersIcon className="mr-3 h-6 w-6" />,
svgIconSelected: <FilledUsersIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/users/,
requiredPermission: Permission.MANAGE_USERS,
dataTestId: 'sidebar-menu-users',
},
{
href: '/settings',
content: intl.formatMessage(menuMessages.settings),
svgIcon: <CogIcon className="mr-3 h-6 w-6" />,
svgIconSelected: <FilledCogIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/settings/,
requiredPermission: Permission.ADMIN,
dataTestId: 'sidebar-menu-settings',
},
];
const filteredLinks = menuLinks.filter(
(link) =>
!link.requiredPermission ||
hasPermission(link.requiredPermission, {
type: link.permissionType ?? 'and',
})
);
return (
<div className="fixed bottom-0 left-0 right-0 z-50">
<Transition
show={isOpen}
as="div"
ref={ref}
enter="transition transform duration-500"
enterFrom="opacity-0 translate-y-0"
enterTo="opacity-100 -translate-y-full"
leave="transition duration-500 transform"
leaveFrom="opacity-100 -translate-y-full"
leaveTo="opacity-0 translate-y-0"
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
>
{filteredLinks.map((link) => {
const isActive = router.pathname.match(link.activeRegExp);
return (
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
<a
className={`flex items-center space-x-2 ${
isActive ? 'text-indigo-500' : ''
}`}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setIsOpen(false);
}
}}
onClick={() => setIsOpen(false)}
role="button"
tabIndex={0}
>
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
className: 'h-5 w-5',
})}
<span>{link.content}</span>
</a>
</Link>
);
})}
</Transition>
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800 bg-opacity-90 backdrop-blur">
<div className="flex h-full items-center justify-between px-6 py-4 text-gray-100">
{filteredLinks.slice(0, 4).map((link) => {
const isActive =
router.pathname.match(link.activeRegExp) && !isOpen;
return (
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
<a
className={`flex flex-col items-center space-y-1 ${
isActive ? 'text-indigo-500' : ''
}`}
>
{cloneElement(
isActive ? link.svgIconSelected : link.svgIcon,
{
className: 'h-6 w-6',
}
)}
</a>
</Link>
);
})}
{filteredLinks.length > 4 && (
<button
className={`flex flex-col items-center space-y-1 ${
isOpen ? 'text-indigo-500' : ''
}`}
onClick={() => toggle()}
>
{isOpen ? (
<XMarkIcon className="h-6 w-6" />
) : (
<EllipsisHorizontalIcon className="h-6 w-6" />
)}
</button>
)}
</div>
</div>
</div>
);
};
export default MobileMenu;

View File

@@ -1,4 +1,4 @@
import { BellIcon } from '@heroicons/react/outline';
import { BellIcon } from '@heroicons/react/24/outline';
const Notifications = () => {
return (

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