Compare commits

..

169 Commits

Author SHA1 Message Date
semantic-release-bot
7e4175ff21 chore(release): 1.13.0 2020-12-23 12:44:01 +00:00
sct
4157432aa7 Merge branch 'develop' 2020-12-23 21:39:00 +09:00
Weblate (bot)
941fe19904 feat(lang): translations update from Weblate (#410)
* feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (374 of 374 strings)

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

Currently translated at 100.0% (374 of 374 strings)

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

Currently translated at 99.7% (364 of 365 strings)

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

Currently translated at 81.8% (297 of 363 strings)

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

Currently translated at 73.0% (265 of 363 strings)

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

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

Currently translated at 100.0% (377 of 377 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (374 of 374 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (374 of 374 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.7% (368 of 369 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (366 of 366 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (365 of 365 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (363 of 363 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 97.8% (366 of 374 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (365 of 365 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (363 of 363 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: sebstrgg <sebastian@wollter.nu>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 99.7% (376 of 377 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 100.0% (374 of 374 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 100.0% (374 of 374 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 100.0% (374 of 374 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 99.7% (368 of 369 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 100.0% (366 of 366 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 100.0% (365 of 365 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 100.0% (363 of 363 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 100.0% (363 of 363 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (366 of 366 strings)

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

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

Currently translated at 100.0% (377 of 377 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (374 of 374 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (374 of 374 strings)

feat(lang): translated using Weblate (German)

Currently translated at 99.7% (368 of 369 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (366 of 366 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (365 of 365 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (363 of 363 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 93.4% (341 of 365 strings)

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

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

Currently translated at 98.1% (367 of 374 strings)

feat(lang): translated using Weblate (Japanese)

Currently translated at 100.0% (366 of 366 strings)

feat(lang): translated using Weblate (Japanese)

Currently translated at 95.9% (351 of 366 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: T'ai <chivalrousjosh@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ja/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: Tijuco <linuxinmotion@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: sebstrgg <sebastian@wollter.nu>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Co-authored-by: Shutruk <cokimaya007@gmail.com>
Co-authored-by: BodingClockchian <joost_nl@live.nl>
Co-authored-by: T'ai <chivalrousjosh@gmail.com>
2020-12-23 21:38:11 +09:00
sct
2e0e4d5129 fix(frontend): filter out undefined backdrop paths for person details page 2020-12-23 17:18:00 +09:00
sct
9f5f920c23 fix(frontend): show backdrops instead of posters for new person detail design 2020-12-23 16:14:39 +09:00
sct
1fb7ea7258 feat(frontend): adjust person details design and add improved truncate 2020-12-23 16:02:55 +09:00
Brandon Cohen
3bbc716434 fix(frontend): added new Radarr v3 logo (#471) 2020-12-23 13:05:40 +09:00
sct
6898357b13 feat(email): option to allow self signed certificates 2020-12-23 12:18:40 +09:00
Brandon Cohen
a7db01fba4 feat(frontend): first air date added to TV details page (#470) 2020-12-23 12:15:05 +09:00
sct
0c8a180189 fix: clarify full sync runs every 24 hours 2020-12-23 01:40:46 +09:00
sct
0980fa54f9 fix(email): correctly log errors when emails fail to send 2020-12-23 01:34:29 +09:00
sct
32b4c99950 fix(api): correctly return firstAirDate for series in search endpoints
fixes #462
2020-12-23 01:13:29 +09:00
sct
22002ab4c7 feat(logs): rotate log files if they reach 20MB in size
closes #438
2020-12-23 00:58:33 +09:00
sct
659fa505f0 feat(notifications): include direct links to media in notifications
closes #437
2020-12-23 00:25:44 +09:00
sct
4205e32ae7 fix(plex-sync): match correct tmdb format for movies 2020-12-22 22:37:18 +09:00
sct
ffe9e19c3b feat(plex-sync): add support for hama guid's
re #453
2020-12-22 13:45:04 +09:00
Brandon Cohen
66ef72dd42 fix(frontend): approve and decline button (in manage panel) will now fit on mobile (#441) 2020-12-22 13:19:06 +09:00
samwiseg0
1056d28a26 docs: Update templates with new labels [skip ci] (#452)
* docs: Update bug template with new label [skip ci]

* docs: Update feature request template with new label [skip ci]
2020-12-22 12:50:09 +09:00
semantic-release-bot
741b2c4e9f chore(release): 1.12.1 2020-12-22 03:17:58 +00:00
sct
6dbe9ea814 Merge branch 'develop' 2020-12-22 12:12:25 +09:00
sct
69fd7a5511 fix(migration): fixes issue migrating away from the unique imdbId constraint 2020-12-22 12:10:57 +09:00
semantic-release-bot
96328ab5c6 chore(release): 1.12.0 2020-12-22 02:26:31 +00:00
sct
c31709681a Merge branch 'develop' 2020-12-22 11:21:19 +09:00
sct
4b505223b8 fix(logs): improve logging when adding to sonarr/radarr 2020-12-22 11:12:41 +09:00
sct
16e48c4919 build: remove yml from dockerignore [skip ci] 2020-12-22 09:46:22 +09:00
sct
604ba2a92f feat(frontend): add full crew page for movies/shows 2020-12-22 00:37:26 +09:00
sct
12127a7763 feat(frontend): add crew related movies/shows to person details page 2020-12-22 00:12:50 +09:00
allcontributors[bot]
9a4da25faa docs: add johnpyp as a contributor (#435) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-21 23:42:25 +09:00
johnpyp
7e8f361af7 feat: import users from plex (#428)
* feat: import users from plex

fix #281

* fix(frontend): re-enable delete user confirmation button after finished
2020-12-21 23:41:19 +09:00
allcontributors[bot]
08c22b03e0 docs: add doob187 as a contributor (#434) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-21 23:12:39 +09:00
doob187
3cee6b78a4 build: Update Docker ignore (#425)
Add some files to ignore.
Inside the Docker  ( latest dev Build )
```
CODE_OF_CONDUCT.md
CONTRIBUTING.md
Dockerfile
Dockerfile.local
LICENSE
babel.config.js
config
dist
docker-compose.yml
next-env.d.ts
next.config.js
node_modules
ormconfig.js
overseerr-api.yml
package.json
postcss.config.js
public
server
src
stylelint.config.js
tailwind.config.js
tsconfig.json
yarn.lock
```
2020-12-21 23:07:54 +09:00
sct
7083ddf181 revert(deps): revert react-use-clipboard to 1.0.2 2020-12-21 21:25:14 +09:00
sct
b092361c73 build(deps): more bundled dependency updates 2020-12-21 21:15:15 +09:00
sct
6f2a71c788 build(deps-dev): bundled dependency update 2020-12-21 21:08:46 +09:00
sct
22006e9dbd fix(api): prevent checking first admin account for plex server access 2020-12-21 20:51:54 +09:00
sct
e7ee85c29b feat: default user permissions added to settings
closes #388
2020-12-21 18:47:55 +09:00
sct
f35dae56a5 fix(api): fix cross-imported type crashing build 2020-12-21 18:14:49 +09:00
sct
5deb64a87f fix(frontend): allow more special characters in search input
fixes #430
2020-12-21 18:02:23 +09:00
sct
6753d9daaa feat(frontend): add prioritized crew under overview
closes #406
2020-12-21 17:37:38 +09:00
sct
4891298891 fix(frontend): adjust person card layout to deal with overflowing content
fixes #416
2020-12-21 15:45:55 +09:00
sct
ab9cef3624 fix: only run migrations in production 2020-12-21 14:38:32 +09:00
allcontributors[bot]
d4d79e438c docs: add danshilm as a contributor (#429) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-21 14:37:26 +09:00
Danshil Mungur
c6836e02c8 fix(frontend): add name, short_name and start_url to manifest (#424) 2020-12-21 14:36:11 +09:00
sct
0916b58594 fix(notifications): always update the media table when seasons become available 2020-12-21 01:40:23 +09:00
sct
44a305426f feat(notifications): added ability to send test notifications
closes #309
2020-12-20 21:41:25 +09:00
sct
764db94f1b fix(plex-sync): fixes processing movies using TMDB agent
fix #363
2020-12-20 11:00:48 +09:00
semantic-release-bot
0a3d7c5915 chore(release): 1.11.0 2020-12-20 01:18:39 +00:00
sct
912c63e562 Merge branch 'develop' 2020-12-20 10:12:59 +09:00
allcontributors[bot]
f6865ca0c1 docs: add sebstrgg as a contributor (#407) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-20 01:45:47 +09:00
sct
c9fe6cb0b7 feat(lang): add support for swedish language 2020-12-20 01:43:54 +09:00
Weblate (bot)
3778ad829c feat(lang): translations update from Weblate (#403)
* feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (350 of 350 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 75.7% (265 of 350 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 75.7% (265 of 350 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 72.5% (254 of 350 strings)

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: sebstrgg <sebastian@wollter.nu>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (350 of 350 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (350 of 350 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: sebstrgg <sebastian@wollter.nu>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
2020-12-20 01:40:56 +09:00
sct
b1786245c7 ci: change semantic-release/github to add releases to bottom [skip ci] 2020-12-20 01:34:50 +09:00
sct
b7f5739035 feat(frontend): releases added to about page
closes #303
2020-12-20 01:22:58 +09:00
sct
c128898206 feat(frontend): add support overseerr block to about page 2020-12-19 23:30:27 +09:00
sct
ff2ab29491 feat(frontend): add language picker to setup/login 2020-12-19 23:13:46 +09:00
sct
108dfc4afd feat(lang): add support for Italian, Portuguese (Brazil) and Serbian 2020-12-19 21:51:05 +09:00
allcontributors[bot]
6f2de8577e docs: add Shagon94 as a contributor (#401) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-19 21:31:51 +09:00
Weblate (bot)
1bd0e646e3 feat(lang): translations update from Weblate (#400)
* feat(lang): translated using Weblate (French)

Currently translated at 96.5% (338 of 350 strings)

Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (350 of 350 strings)

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

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

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

Currently translated at 62.8% (220 of 350 strings)

Co-authored-by: Tijuco <linuxinmotion@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (350 of 350 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 100.0% (350 of 350 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 93.7% (328 of 350 strings)

Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 96.2% (337 of 350 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 95.7% (335 of 350 strings)

Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 23.4% (82 of 350 strings)

Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ru/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 99.4% (348 of 350 strings)

feat(lang): translated using Weblate (Japanese)

Currently translated at 99.4% (348 of 350 strings)

Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Co-authored-by: T'ai <chivalrousjosh@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ja/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (350 of 350 strings)

Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/en/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Co-authored-by: Shagon94 <Shagon94@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Tijuco <linuxinmotion@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: T'ai <chivalrousjosh@gmail.com>
2020-12-19 21:31:07 +09:00
semantic-release-bot
077513151e chore(release): 1.10.0 2020-12-19 04:54:22 +00:00
sct
191f39e8bd Merge branch 'develop' 2020-12-19 13:49:05 +09:00
sct
fb883e8043 Merge branch 'develop' of github.com:sct/overseerr into develop 2020-12-19 13:44:57 +09:00
allcontributors[bot]
4435468561 docs: add Panzer1119 as a contributor (#396) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-19 13:44:48 +09:00
allcontributors[bot]
e821d0a02b docs: add mazzetta86 as a contributor (#395) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-19 13:43:53 +09:00
Weblate (bot)
5f71fb7ee2 feat(lang): translations update from Weblate (#391)
* feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 17.4% (61 of 350 strings)

feat(lang): added translation using Weblate (Portuguese (Brazil))

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Tijuco <linuxinmotion@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (350 of 350 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 90.8% (318 of 350 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 90.8% (318 of 350 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 46.2% (162 of 350 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 27.4% (96 of 350 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 27.4% (96 of 350 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (350 of 350 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (350 of 350 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Paul <panzer1119@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: Tijuco <linuxinmotion@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Co-authored-by: Paul <panzer1119@gmail.com>
2020-12-19 13:42:19 +09:00
sct
a0c5608aa0 fix(frontend): try not to render broken rottentomatoes data 2020-12-19 13:39:48 +09:00
sct
816fec1a83 fix(email): fix link to Overseerr in email templates
fixes #392
2020-12-19 13:23:15 +09:00
sct
2a4dd52275 fix(frontend): hide Request More button if all current seasons are available
fixes #343
2020-12-19 13:16:58 +09:00
sct
3601d442db fix(frontend): adjust padding of search box so placeholder text fits on mobile
this only fixes English. Other languages placeholders may or may not fit into the space

fixes #393
2020-12-19 12:57:17 +09:00
sct
53bede692d feat: add missing tzdata package to image
re #394
2020-12-19 11:32:17 +09:00
sct
fe46895289 ci: add @semantic-release/github step to publish array 2020-12-19 03:09:42 +09:00
sct
a0024a0cbe feat(frontend): add external links to movie and tv detail pages 2020-12-19 02:46:32 +09:00
Weblate (bot)
8408e19568 feat(lang): translations update from Weblate (#380)
* feat(lang): translated using Weblate (Italian)

Currently translated at 2.0% (7 of 350 strings)

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (340 of 340 strings)

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

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

Currently translated at 100.0% (340 of 340 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (340 of 340 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Shutruk <cokimaya007@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
2020-12-19 02:01:47 +09:00
sct
ed49e7c28e docs: add FUNDING.yml 2020-12-19 01:55:23 +09:00
Brandon Cohen
549567a7e9 fix(frontend): changed request block for slideover on mobile UI (#387) 2020-12-19 01:55:11 +09:00
sct
51f99a2e7b ci: add missing build-args back into ci workflow 2020-12-19 01:24:50 +09:00
semantic-release-bot
048b6c1d90 chore(release): 1.9.1 2020-12-18 16:08:58 +00:00
sct
a6a065ac0b Merge branch 'develop' 2020-12-19 01:03:48 +09:00
sct
5e5ba40505 fix: change default internal port to 5055 (#389) 2020-12-19 00:12:41 +09:00
semantic-release-bot
abb0b2d763 chore(release): 1.9.0 2020-12-18 12:05:35 +00:00
sct
d2668d3f49 Merge branch 'develop' 2020-12-18 21:00:48 +09:00
ecelebi29
36b2b4fba4 docs: fixed multiple typos, updated latest en.json strings (#371) [skip ci] 2020-12-18 20:45:31 +09:00
sct
6beac736ef feat: api key regeneration 2020-12-18 19:40:21 +09:00
sct
6933b661ca refactor(frontend): remove notification bell from user bar for now 2020-12-18 19:11:39 +09:00
sct
4b6ad8a387 feat(frontend): add studio/networks to movie/tv details
closes #370
2020-12-18 19:01:56 +09:00
sct
992a23222a ci: add @semantic-release/github to release plugins 2020-12-18 18:37:52 +09:00
sct
e75b592bb2 ci: fix image name for ghcr.io tags 2020-12-18 07:29:23 +00:00
sct
baa62b286d ci: push develop images to ghcr.io 2020-12-18 07:04:17 +00:00
sct
908f63557c feat(holiday): special seasonal slider added to discover :) 2020-12-18 06:14:41 +00:00
sct
f88c4a6d4a feat(api): add movie keyword search 2020-12-18 06:13:44 +00:00
sct
0972f40a4e feat: anime profile support (#384)
closes #266
2020-12-18 14:32:40 +09:00
allcontributors[bot]
1f0486eba2 docs: add mmozeiko as a contributor (#382) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-18 11:12:30 +09:00
Mārtiņš Možeiko
086183b563 feat: allow to listen server on specific host interface (#381)
fix #273
2020-12-18 11:11:20 +09:00
samwiseg0
df4ef1fbe5 docs: Remove the issue lock as it will not allow users to edit [skip ci] (#376) 2020-12-18 08:06:14 +09:00
sct
727fa06c18 feat(frontend): added user deletion to the user list
also includes small updates to the api to prevent administrators from being deleted, as well as
migrations to cascade deletions to requests the users made

fixes #348
2020-12-17 16:03:27 +00:00
semantic-release-bot
bee6eb288f chore(release): 1.8.0 2020-12-17 15:54:40 +00:00
sct
3473aed27e Merge branch 'develop' 2020-12-17 15:49:18 +00:00
semantic-release-bot
a9144a2167 chore(release): 1.7.0 2020-12-17 14:09:58 +00:00
sct
59822d6e42 Merge branch 'develop' 2020-12-17 14:05:24 +00:00
Weblate (bot)
ee84f74f8a feat(lang): translations update from Weblate (#336)
* feat(lang): translated using Weblate (Spanish)

Currently translated at 96.4% (323 of 335 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (323 of 323 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (321 of 321 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: Shutruk <cokimaya007@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (323 of 323 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (323 of 323 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (321 of 321 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (335 of 335 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 95.2% (319 of 335 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 95.2% (319 of 335 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 98.7% (319 of 323 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jos Vennik <josvennik@gmail.com>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (323 of 323 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (323 of 323 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (321 of 321 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: Shutruk <cokimaya007@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Jos Vennik <josvennik@gmail.com>
2020-12-17 22:54:41 +09:00
sct
18925decaf fix(frontend): correctly show an unauthorized error when a user fails to login
fixes #322
2020-12-17 12:05:45 +00:00
sct
67146c33ef fix(plex-sync): bundle duplicate ratingKeys to speed up recently added sync
This includes a rewrite to move movie/series availability notifications into a subscriber to prevent
duplicate notifications for series

fix #360
2020-12-17 06:28:03 +00:00
sct
d5eb4d8d43 fix(email): do not pass auth object to transport if no auth data present
re #312
2020-12-17 04:06:45 +00:00
sct
fc12ab84d9 fix(frontend): clarify that radarr/sonnarr servers must be tested before profiles/folders appear
Also blocks "Add Server" or "Save" button until all required fields are entered

fixes #326 and #328
2020-12-17 03:28:49 +00:00
sct
0d088e085e feat(frontend): show alert when there are no default radarr/sonarr servers
fixes #344
2020-12-17 02:08:09 +00:00
sct
2fe53ec5a8 fix(frontend): make minimum availability required for Radarr servers
fixes #345
2020-12-17 01:42:21 +00:00
sct
ce0266f74e fix(frontend): add http/https prefix to hostname fields for plex/radarr/sonarr
fixes #357
2020-12-17 01:36:23 +00:00
sct
c21fa5b535 fix(frontend): fix tv shows failing to open when firstAirDate is undefined
fix #347
2020-12-17 01:14:12 +00:00
allcontributors[bot]
647d5efb09 docs: add ecelebi29 as a contributor (#362) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-17 10:07:40 +09:00
ecelebi29
bb89f679b6 docs(contributing.md, readme.md): fixed 2 small typos (#361) [skip ci] 2020-12-17 10:07:07 +09:00
allcontributors[bot]
3210ac29d0 docs: add ecelebi29 as a contributor (#359) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-17 09:20:28 +09:00
ecelebi29
e9c899ce41 fix(sonarr.ts, mediarequest.ts): add missing seasonFolder option (#358) 2020-12-17 09:18:18 +09:00
semantic-release-bot
0bd1bee1fa chore(release): 1.6.0 2020-12-16 05:56:57 +00:00
sct
46326f9d16 Merge branch 'develop' 2020-12-16 05:52:26 +00:00
allcontributors[bot]
ad18a40969 docs: add samwiseg0 as a contributor (#341) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-16 14:06:25 +09:00
samwiseg0
ae2ede8952 docs: add invalid template workflow (#340) 2020-12-16 13:56:52 +09:00
sct
675060bcdf fix(api): set plex libraries to disabled if the name changes
re #324
2020-12-16 04:07:59 +00:00
sct
f8f388db6d refactor(api): add better error context when plex items fail to sync 2020-12-16 03:57:03 +00:00
sct
2948f9360e feat: add version to startup logs 2020-12-16 03:54:32 +00:00
sct
df4ac8361f fix(frontend): adds a tip to plex setup to clarify that syncing runs in the background
fixes #325
2020-12-16 02:35:15 +00:00
sct
92a874e36d docs: update readme.md with emoji key for contributors 2020-12-16 02:07:49 +00:00
Brandon Cohen
6d7907e844 fix(frontend): close sidebar when clicking outside (#333) 2020-12-16 10:58:41 +09:00
sct
d9788c4aa9 feat(frontend): temporary logs page to clear up confusion about it 404ing
This commit also disables the "Run Now" buttons under Jobs until they actually work

re #272
2020-12-16 01:35:26 +00:00
Brandon Cohen
db0a5c44f6 fix(frontend): aligned movie and tv details (#331) 2020-12-16 09:44:58 +09:00
allcontributors[bot]
c545ec16e7 docs: add kieron as a contributor (#329) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-16 09:29:17 +09:00
Kieron Boswell
961d110720 fix: spelling mistake on the word 'requested' fixed (#319) 2020-12-16 09:28:09 +09:00
sct
e8776fd336 refactor(frontend): titlecard behavior changed to allow clicking anywhere to go through to title
mobile behavior remains mostly the same, except after the first click, a second click anywhere else
will go through to the title.
2020-12-15 15:49:59 +00:00
allcontributors[bot]
7c08809501 docs: add krystiancharubin as a contributor (#311) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-16 00:43:31 +09:00
Krystian Charubin
7fd43c9529 style: dark splash screen (#310) 2020-12-16 00:42:27 +09:00
sct
33f8831e88 fix(api): accept the api key to perform actions on the api with X-API-Key header 2020-12-15 14:13:41 +00:00
sct
20b119c1ac docs: update bug report issue template to include app version [skip ci] 2020-12-15 11:55:24 +00:00
sct
6cd20491d2 feat(lang): add support for Spanish language 2020-12-15 11:46:43 +00:00
allcontributors[bot]
cb8a50bd66 docs: add Shutruk as a contributor (#304) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-15 20:40:15 +09:00
Weblate (bot)
fddbb3cdfe feat(lang): Translations update from Weblate (#291)
* feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (321 of 321 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 56.0% (180 of 321 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 52.6% (169 of 321 strings)

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TheRaZex <therazex@protonmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (310 of 310 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (314 of 314 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (310 of 310 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jos Vennik <josvennik@gmail.com>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (321 of 321 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: T'ai <chivalrousjosh@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ja/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (310 of 310 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: TheRaZex <therazex@protonmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: Jos Vennik <josvennik@gmail.com>
Co-authored-by: T'ai <chivalrousjosh@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
2020-12-15 20:39:14 +09:00
sct
01c179f762 fix(api): filter out libraries that do not have any metadata agent or are not movie/show 2020-12-15 10:24:39 +00:00
sct
e08fa35548 fix(api): only run recently added sync on enabled libraries
fixes #259
2020-12-15 10:23:19 +00:00
sct
d96d65b8af refactor(api): increased plex sync speed and changed full sync to run every 24 hours
With this change, plex sync will now process 20 items at a time every 4 seconds. (About double its
previous speed). We will see how much faster we can push this in the future.
2020-12-15 09:16:35 +00:00
semantic-release-bot
54b96cc451 chore(release): 1.5.0 2020-12-15 09:05:37 +00:00
sct
7d2a187865 Merge branch 'develop' 2020-12-15 09:00:44 +00:00
sct
900827be97 feat(lang): add i18n strings for new about page 2020-12-15 08:08:01 +00:00
sct
b9faa6486b fix(api): require package.json directly so typescript doesnt compile it into dist folder 2020-12-15 07:56:53 +00:00
sct
3f2a04c881 feat: about page initial version
Adding something to the about page for now, including the app version to better know what versions
of the app people are running
2020-12-15 07:27:30 +00:00
sct
ff618956b5 fix(frontend): only show alpha notice to admins 2020-12-15 05:15:45 +00:00
sct
b5988f9a5f fix(frontend): add validation for Radarr/Sonarr server name 2020-12-15 03:30:13 +00:00
sct
3ba09d07eb fix: add support for ssl when connecting to plex
fixes #275
2020-12-15 03:22:54 +00:00
Alex Zoitos
f998873fc5 fix(services): improve logging for when Radarr movie already exists (#285)
re #260
2020-12-15 11:11:48 +09:00
allcontributors[bot]
33b54c08b5 docs: add saltydk as a contributor (#286) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-15 11:05:19 +09:00
salty
34136ef617 build(dockerfile): Expose port 3000. (#278)
Allows for automated nginx reverse proxy setups to identify the port used in the container without having to bind the port to the host/localhost.
2020-12-15 10:57:01 +09:00
semantic-release-bot
17df5dd072 chore(release): 1.4.0 2020-12-15 00:53:05 +00:00
sct
4b48a36ad3 Merge branch 'develop' 2020-12-15 00:48:28 +00:00
allcontributors[bot]
2d3359a7e3 docs: add darknessgp as a contributor (#279) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-15 09:15:18 +09:00
darknessgp
6d08b10820 fix: changing parameter name to use correct 'port' #276 (#277)
Co-authored-by: Ryan Boyles <Ryan.Boyles@ahstech.com>
2020-12-15 09:14:11 +09:00
sct
78af1a3e6d fix(services): include radarr/sonarr baseUrl when adding media 2020-12-14 16:39:01 +00:00
semantic-release-bot
16bd3e9fd5 chore(release): 1.3.2 2020-12-14 13:11:15 +00:00
sct
6360c82898 Merge branch 'develop' 2020-12-14 13:06:22 +00:00
allcontributors[bot]
67a8f7fbfe docs: add jvennik as a contributor (#253) [skip ci]
* docs: update README.md [skip ci]

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

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2020-12-14 19:08:45 +09:00
Weblate (bot)
e17c637483 feat(lang): Translations update from Weblate (#240)
* feat(lang): translated using Weblate (German)

Currently translated at 100.0% (306 of 306 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (310 of 310 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (306 of 306 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 88.8% (272 of 306 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 72.5% (222 of 306 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 69.9% (214 of 306 strings)

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

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

Currently translated at 100.0% (306 of 306 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (306 of 306 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (306 of 306 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: NGVICIOUS <giyomu1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: Jos Vennik <josvennik@gmail.com>
Co-authored-by: NGVICIOUS <giyomu1@gmail.com>
2020-12-14 19:07:46 +09:00
sct
15013d6c5d fix(frontend): encode special characters in search input to prevent crashing router
re #252
2020-12-14 09:38:38 +00:00
sct
8cb05c413a fix(frontend): convert plex port to a number before posting to the api 2020-12-14 09:13:59 +00:00
sct
2098a2d3d2 fix(frontend): converts email smtp port to a number before posting to the api
- also adds toast notifications to both email/discord notifications pages to know when sucesses or
failures occur

re #251
2020-12-14 09:00:10 +00:00
sct
6c1ee830a1 fix(services): improve logging for adding movies to Radarr 2020-12-14 08:17:38 +00:00
sct
0658b7943e fix(services): radarr/sonarr will use the correct default server 2020-12-14 08:14:08 +00:00
semantic-release-bot
f1df9eeb2f chore(release): 1.3.1 2020-12-14 08:03:51 +00:00
sct
edbbccf3ae fix(plex sync): catch errors that occur during processMovie
this also removes the unique constraint on imdbId

re #244 #246 #250
2020-12-14 08:01:33 +00:00
sct
a3973791fb Merge branch 'develop' 2020-12-14 07:59:18 +00:00
sct
cb5c0403c1 docs: update bug template with docker version question [skip ci] 2020-12-14 07:57:33 +00:00
sct
7bf924f7e9 fix(frontend): also convert activeProfileId to a number for radarr/sonarr submissions 2020-12-14 07:17:32 +00:00
sct
c53dc3b15d fix(frontend): also convert ports to numbers when saving radarr/sonarr servers 2020-12-14 07:02:29 +00:00
sct
b96bd33982 refactor(frontend): remove search filter buttons until they actually do something 2020-12-14 06:40:31 +00:00
sct
92c9001c9d fix(frontend): new radarr/sonarr ports will be converted to a number before posting 2020-12-14 06:37:34 +00:00
130 changed files with 7972 additions and 1792 deletions

View File

@@ -70,6 +70,152 @@
"contributions": [
"doc"
]
},
{
"login": "jvennik",
"name": "jvennik",
"avatar_url": "https://avatars3.githubusercontent.com/u/6672637?v=4",
"profile": "https://github.com/jvennik",
"contributions": [
"translation"
]
},
{
"login": "darknessgp",
"name": "darknessgp",
"avatar_url": "https://avatars0.githubusercontent.com/u/1521243?v=4",
"profile": "https://github.com/darknessgp",
"contributions": [
"code"
]
},
{
"login": "saltydk",
"name": "salty",
"avatar_url": "https://avatars1.githubusercontent.com/u/6587950?v=4",
"profile": "https://github.com/saltydk",
"contributions": [
"infra"
]
},
{
"login": "Shutruk",
"name": "Shutruk",
"avatar_url": "https://avatars2.githubusercontent.com/u/9198633?v=4",
"profile": "https://github.com/Shutruk",
"contributions": [
"translation"
]
},
{
"login": "krystiancharubin",
"name": "Krystian Charubin",
"avatar_url": "https://avatars2.githubusercontent.com/u/17775600?v=4",
"profile": "https://github.com/krystiancharubin",
"contributions": [
"design"
]
},
{
"login": "kieron",
"name": "Kieron Boswell",
"avatar_url": "https://avatars2.githubusercontent.com/u/8655212?v=4",
"profile": "https://github.com/kieron",
"contributions": [
"code"
]
},
{
"login": "samwiseg0",
"name": "samwiseg0",
"avatar_url": "https://avatars1.githubusercontent.com/u/2241731?v=4",
"profile": "https://github.com/samwiseg0",
"contributions": [
"question",
"infra"
]
},
{
"login": "ecelebi29",
"name": "ecelebi29",
"avatar_url": "https://avatars2.githubusercontent.com/u/8337120?v=4",
"profile": "https://github.com/ecelebi29",
"contributions": [
"code",
"doc"
]
},
{
"login": "mmozeiko",
"name": "Mārtiņš Možeiko",
"avatar_url": "https://avatars3.githubusercontent.com/u/1665010?v=4",
"profile": "https://github.com/mmozeiko",
"contributions": [
"code"
]
},
{
"login": "mazzetta86",
"name": "mazzetta86",
"avatar_url": "https://avatars2.githubusercontent.com/u/45591560?v=4",
"profile": "https://github.com/mazzetta86",
"contributions": [
"translation"
]
},
{
"login": "Panzer1119",
"name": "Paul Hagedorn",
"avatar_url": "https://avatars1.githubusercontent.com/u/23016343?v=4",
"profile": "https://github.com/Panzer1119",
"contributions": [
"translation"
]
},
{
"login": "Shagon94",
"name": "Shagon94",
"avatar_url": "https://avatars3.githubusercontent.com/u/9140783?v=4",
"profile": "https://github.com/Shagon94",
"contributions": [
"translation"
]
},
{
"login": "sebstrgg",
"name": "sebstrgg",
"avatar_url": "https://avatars3.githubusercontent.com/u/27026694?v=4",
"profile": "https://github.com/sebstrgg",
"contributions": [
"translation"
]
},
{
"login": "danshilm",
"name": "Danshil Mungur",
"avatar_url": "https://avatars2.githubusercontent.com/u/20923978?v=4",
"profile": "https://github.com/danshilm",
"contributions": [
"code"
]
},
{
"login": "doob187",
"name": "doob187",
"avatar_url": "https://avatars1.githubusercontent.com/u/60312740?v=4",
"profile": "https://github.com/doob187",
"contributions": [
"infra"
]
},
{
"login": "johnpyp",
"name": "johnpyp",
"avatar_url": "https://avatars2.githubusercontent.com/u/20625636?v=4",
"profile": "https://github.com/johnpyp",
"contributions": [
"code"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

View File

@@ -4,7 +4,12 @@ node_modules
.gitconfig
.gitignore
.github
.all-contributorsrc
.editorconfig
.prettierignore
**/README.md
**/.vscode
config/db/db.sqlite3
config/db/logs/overseerr.log
Dockerfil**
**.md

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
github: [sct]
patreon: overseerr

View File

@@ -2,13 +2,18 @@
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
labels: 'awaiting-triage, type:bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**What version of Overseerr are you running?**
Please fill in the version you are currently running.
You can find it under: Settings -> About -> Version
**To Reproduce**
Steps to reproduce the behavior:

View File

@@ -2,7 +2,7 @@
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
labels: 'awaiting-triage, type:enhancement'
assignees: ''
---

View File

@@ -29,12 +29,33 @@ jobs:
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'skip ci')
runs-on: ubuntu-latest
steps:
- name: checkout
- name: Checkout
uses: actions/checkout@v2
- name: Build and push to Docker Hub
uses: docker/build-push-action@v1
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: sctx/overseerr
tags: develop
password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}
tags: |
sctx/overseerr:develop
sctx/overseerr:${{ github.sha }}
ghcr.io/sct/overseerr:develop
ghcr.io/sct/overseerr:${{ github.sha }}

19
.github/workflows/invalid_template.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: 'Invalid Template'
on:
issues:
types: [labeled, unlabeled, reopened]
jobs:
support:
runs-on: ubuntu-latest
steps:
- uses: dessant/support-requests@v2
with:
github-token: ${{ github.token }}
support-label: 'invalid:template-incomplete'
issue-comment: >
:wave: @{issue-author}, please edit your issue and follow the template provided.
close-issue: false
lock-issue: false
issue-lock-reason: 'resolved'

2
.gitignore vendored
View File

@@ -36,7 +36,7 @@ config/db/db.sqlite3
config/settings.json
# logs
config/logs/*.log
config/logs/*.log*
# dist files
dist

View File

@@ -1,3 +1,216 @@
# [1.13.0](https://github.com/sct/overseerr/compare/v1.12.1...v1.13.0) (2020-12-23)
### Bug Fixes
* **api:** correctly return firstAirDate for series in search endpoints ([32b4c99](https://github.com/sct/overseerr/commit/32b4c99950659d9e1da2ffa93c22383c54d0d904)), closes [#462](https://github.com/sct/overseerr/issues/462)
* **email:** correctly log errors when emails fail to send ([0980fa5](https://github.com/sct/overseerr/commit/0980fa54f9fc3bdfae6c57fa5a20ce3b2a88a677))
* **frontend:** added new Radarr v3 logo ([#471](https://github.com/sct/overseerr/issues/471)) ([3bbc716](https://github.com/sct/overseerr/commit/3bbc716434dc04bfe6b55de9898eb2c0ecb03baa))
* **frontend:** approve and decline button (in manage panel) will now fit on mobile ([#441](https://github.com/sct/overseerr/issues/441)) ([66ef72d](https://github.com/sct/overseerr/commit/66ef72dd42912d83ea8f86aabb75fbee547f8de9))
* **frontend:** filter out undefined backdrop paths for person details page ([2e0e4d5](https://github.com/sct/overseerr/commit/2e0e4d5129ed4912415f61eb8d1da41e88ddcaff))
* **frontend:** show backdrops instead of posters for new person detail design ([9f5f920](https://github.com/sct/overseerr/commit/9f5f920c23007363aa7f53ebef0b61236d4f53ea))
* clarify full sync runs every 24 hours ([0c8a180](https://github.com/sct/overseerr/commit/0c8a180189b2610bab2fa977d458743d8a60343e))
* **plex-sync:** match correct tmdb format for movies ([4205e32](https://github.com/sct/overseerr/commit/4205e32ae71bc18c07209f1c82e6af1cb5f01335))
### Features
* **email:** option to allow self signed certificates ([6898357](https://github.com/sct/overseerr/commit/6898357b13a6aa53a55709ea95819c2b3df6784c))
* **frontend:** adjust person details design and add improved truncate ([1fb7ea7](https://github.com/sct/overseerr/commit/1fb7ea72589d2908ae80a2a688881d4eb3c050e5))
* **frontend:** first air date added to TV details page ([#470](https://github.com/sct/overseerr/issues/470)) ([a7db01f](https://github.com/sct/overseerr/commit/a7db01fba483ca633a6eb9d39eb085ab9939d4d2))
* **lang:** translations update from Weblate ([#410](https://github.com/sct/overseerr/issues/410)) ([941fe19](https://github.com/sct/overseerr/commit/941fe1990454439cf05b48ef92bd3493432f8ed8))
* **logs:** rotate log files if they reach 20MB in size ([22002ab](https://github.com/sct/overseerr/commit/22002ab4c76aace2bb202ac58da605b7a6f75d6d)), closes [#438](https://github.com/sct/overseerr/issues/438)
* **notifications:** include direct links to media in notifications ([659fa50](https://github.com/sct/overseerr/commit/659fa505f0db32262ad0041cddb4daea893e6d65)), closes [#437](https://github.com/sct/overseerr/issues/437)
* **plex-sync:** add support for hama guid's ([ffe9e19](https://github.com/sct/overseerr/commit/ffe9e19c3b99de6af1185900e292da641ff44320)), closes [#453](https://github.com/sct/overseerr/issues/453)
## [1.12.1](https://github.com/sct/overseerr/compare/v1.12.0...v1.12.1) (2020-12-22)
### Bug Fixes
* **migration:** fixes issue migrating away from the unique imdbId constraint ([69fd7a5](https://github.com/sct/overseerr/commit/69fd7a5511215674a5c22ba48627f221da900229))
# [1.12.0](https://github.com/sct/overseerr/compare/v1.11.0...v1.12.0) (2020-12-22)
### Bug Fixes
* **api:** fix cross-imported type crashing build ([f35dae5](https://github.com/sct/overseerr/commit/f35dae56a583a5545375318fa5be994ae1f2557f))
* **api:** prevent checking first admin account for plex server access ([22006e9](https://github.com/sct/overseerr/commit/22006e9dbde82609440f89bde9a40887b4742682))
* **frontend:** add name, short_name and start_url to manifest ([#424](https://github.com/sct/overseerr/issues/424)) ([c6836e0](https://github.com/sct/overseerr/commit/c6836e02c810e8adb12c3a4b110f9604cf5b7b81))
* **frontend:** adjust person card layout to deal with overflowing content ([4891298](https://github.com/sct/overseerr/commit/48912988915ae40606a900a6f1dd23fc25ed567f)), closes [#416](https://github.com/sct/overseerr/issues/416)
* **frontend:** allow more special characters in search input ([5deb64a](https://github.com/sct/overseerr/commit/5deb64a87fd70e97da27a025ad11fb8ace0e0b57)), closes [#430](https://github.com/sct/overseerr/issues/430)
* **logs:** improve logging when adding to sonarr/radarr ([4b50522](https://github.com/sct/overseerr/commit/4b505223b881a750007e3fbc7d4bcb9677d4d412))
* only run migrations in production ([ab9cef3](https://github.com/sct/overseerr/commit/ab9cef3624b5db1ec03507553a69d33b87857e29))
* **notifications:** always update the media table when seasons become available ([0916b58](https://github.com/sct/overseerr/commit/0916b58594a00db98c6701fdcaee4f3c3e08904e))
* **plex-sync:** fixes processing movies using TMDB agent ([764db94](https://github.com/sct/overseerr/commit/764db94f1bd7866309684d5bd56033b21cbc2e0c)), closes [#363](https://github.com/sct/overseerr/issues/363)
### Features
* **frontend:** add crew related movies/shows to person details page ([12127a7](https://github.com/sct/overseerr/commit/12127a77633f0e92ae88cbafd49581296f559c33))
* **frontend:** add full crew page for movies/shows ([604ba2a](https://github.com/sct/overseerr/commit/604ba2a92f1d59489e7fc6dfc011347f8595c123))
* default user permissions added to settings ([e7ee85c](https://github.com/sct/overseerr/commit/e7ee85c29b5d25c6bff58717eae5e62de4dcef0c)), closes [#388](https://github.com/sct/overseerr/issues/388)
* import users from plex ([#428](https://github.com/sct/overseerr/issues/428)) ([7e8f361](https://github.com/sct/overseerr/commit/7e8f361af711001cfc4dcc06a384b76f9846f90f)), closes [#281](https://github.com/sct/overseerr/issues/281)
* **frontend:** add prioritized crew under overview ([6753d9d](https://github.com/sct/overseerr/commit/6753d9daaafb18672f14fd86f2c1675dcec39b13)), closes [#406](https://github.com/sct/overseerr/issues/406)
* **notifications:** added ability to send test notifications ([44a3054](https://github.com/sct/overseerr/commit/44a305426f3e9829c167a4a73095d0d248641f47)), closes [#309](https://github.com/sct/overseerr/issues/309)
### Reverts
* **deps:** revert react-use-clipboard to 1.0.2 ([7083ddf](https://github.com/sct/overseerr/commit/7083ddf18121716e3442acab3506c395fdc351ac))
# [1.11.0](https://github.com/sct/overseerr/compare/v1.10.0...v1.11.0) (2020-12-20)
### Features
* **frontend:** add language picker to setup/login ([ff2ab29](https://github.com/sct/overseerr/commit/ff2ab29491a80c421525b9a394d6fbbf54914dc2))
* **frontend:** add support overseerr block to about page ([c128898](https://github.com/sct/overseerr/commit/c128898206d6cbb482de4d8dca53f70b87e4911a))
* **frontend:** releases added to about page ([b7f5739](https://github.com/sct/overseerr/commit/b7f573903500cc8a62e39afd787bc1da8c09d88b)), closes [#303](https://github.com/sct/overseerr/issues/303)
* **lang:** add support for Italian, Portuguese (Brazil) and Serbian ([108dfc4](https://github.com/sct/overseerr/commit/108dfc4afd31388cb6c9e07deccd168ade8b1574))
* **lang:** add support for swedish language ([c9fe6cb](https://github.com/sct/overseerr/commit/c9fe6cb0b7ea984d8e4e1cb3f284935c9da7cc2b))
* **lang:** translations update from Weblate ([#400](https://github.com/sct/overseerr/issues/400)) ([1bd0e64](https://github.com/sct/overseerr/commit/1bd0e646e313ddf77ef331e818e03401fbf64a72))
* **lang:** translations update from Weblate ([#403](https://github.com/sct/overseerr/issues/403)) ([3778ad8](https://github.com/sct/overseerr/commit/3778ad829c0897de178212b3bde4c0d3b5089161))
# [1.10.0](https://github.com/sct/overseerr/compare/v1.9.1...v1.10.0) (2020-12-19)
### Bug Fixes
* **email:** fix link to Overseerr in email templates ([816fec1](https://github.com/sct/overseerr/commit/816fec1a83a53edb3b65c3e5e7d0e6e1bd49726d)), closes [#392](https://github.com/sct/overseerr/issues/392)
* **frontend:** adjust padding of search box so placeholder text fits on mobile ([3601d44](https://github.com/sct/overseerr/commit/3601d442db32d3f98f7b050365c11ea8ef9bc4ae)), closes [#393](https://github.com/sct/overseerr/issues/393)
* **frontend:** changed request block for slideover on mobile UI ([#387](https://github.com/sct/overseerr/issues/387)) ([549567a](https://github.com/sct/overseerr/commit/549567a7e9db01933546d9970fc06f17218dfab1))
* **frontend:** hide Request More button if all current seasons are available ([2a4dd52](https://github.com/sct/overseerr/commit/2a4dd52275007e48f946c3b9e29f1d78da57bdaa)), closes [#343](https://github.com/sct/overseerr/issues/343)
* **frontend:** try not to render broken rottentomatoes data ([a0c5608](https://github.com/sct/overseerr/commit/a0c5608aa0b6c7a4294300589efa9a662163ce48))
### Features
* **lang:** translations update from Weblate ([#391](https://github.com/sct/overseerr/issues/391)) ([5f71fb7](https://github.com/sct/overseerr/commit/5f71fb7ee280714275d2ac045c472fcdddd5a2ea))
* add missing tzdata package to image ([53bede6](https://github.com/sct/overseerr/commit/53bede692d4f0e940dededa63015fe1908129914)), closes [#394](https://github.com/sct/overseerr/issues/394)
* **frontend:** add external links to movie and tv detail pages ([a0024a0](https://github.com/sct/overseerr/commit/a0024a0cbe717d78f53413bb78644c829f143c4d))
* **lang:** translations update from Weblate ([#380](https://github.com/sct/overseerr/issues/380)) ([8408e19](https://github.com/sct/overseerr/commit/8408e19568b2f239c57e11e2946c75f193d1c22e))
## [1.9.1](https://github.com/sct/overseerr/compare/v1.9.0...v1.9.1) (2020-12-18)
### Bug Fixes
* change default internal port to 5055 ([#389](https://github.com/sct/overseerr/issues/389)) ([5e5ba40](https://github.com/sct/overseerr/commit/5e5ba4050563f07bff367d2fb31ed7e7fca4291e))
# [1.9.0](https://github.com/sct/overseerr/compare/v1.8.0...v1.9.0) (2020-12-18)
### Features
* api key regeneration ([6beac73](https://github.com/sct/overseerr/commit/6beac736efcf7b9102e02e43b75d91a9a158cd22))
* **api:** add movie keyword search ([f88c4a6](https://github.com/sct/overseerr/commit/f88c4a6d4a49f8f3451ba6c85153677f33b7f5f6))
* **frontend:** add studio/networks to movie/tv details ([4b6ad8a](https://github.com/sct/overseerr/commit/4b6ad8a3871957db4192b603abf38404250cea5d)), closes [#370](https://github.com/sct/overseerr/issues/370)
* **frontend:** added user deletion to the user list ([727fa06](https://github.com/sct/overseerr/commit/727fa06c18febb2a97ca219cc6bf0277ff462acd)), closes [#348](https://github.com/sct/overseerr/issues/348)
* **holiday:** special seasonal slider added to discover :) ([908f635](https://github.com/sct/overseerr/commit/908f63557ca03a1da8b16809ffa2c3acd782d94e))
* allow to listen server on specific host interface ([#381](https://github.com/sct/overseerr/issues/381)) ([086183b](https://github.com/sct/overseerr/commit/086183b5636aa8d075d01fe59492c3eab0d1345b)), closes [#273](https://github.com/sct/overseerr/issues/273)
* anime profile support ([#384](https://github.com/sct/overseerr/issues/384)) ([0972f40](https://github.com/sct/overseerr/commit/0972f40a4e1fb3b5f02b07ae46b997d71aab9bfb)), closes [#266](https://github.com/sct/overseerr/issues/266)
# [1.8.0](https://github.com/sct/overseerr/compare/v1.7.0...v1.8.0) (2020-12-17)
### Features
* **lang:** translations update from Weblate ([#336](https://github.com/sct/overseerr/issues/336)) ([ee84f74](https://github.com/sct/overseerr/commit/ee84f74f8a3558875b41daa539f42d00b949898a))
# [1.7.0](https://github.com/sct/overseerr/compare/v1.6.0...v1.7.0) (2020-12-17)
### Bug Fixes
* **email:** do not pass auth object to transport if no auth data present ([d5eb4d8](https://github.com/sct/overseerr/commit/d5eb4d8d438a159266b2de66b6bcdd9440a0c8ef)), closes [#312](https://github.com/sct/overseerr/issues/312)
* **frontend:** add http/https prefix to hostname fields for plex/radarr/sonarr ([ce0266f](https://github.com/sct/overseerr/commit/ce0266f74ea3979b291ff962271a928682892788)), closes [#357](https://github.com/sct/overseerr/issues/357)
* **frontend:** clarify that radarr/sonnarr servers must be tested before profiles/folders appear ([fc12ab8](https://github.com/sct/overseerr/commit/fc12ab84d9482eb3a11f117f8cab6fd48a9401cd)), closes [#326](https://github.com/sct/overseerr/issues/326) [#328](https://github.com/sct/overseerr/issues/328)
* **frontend:** correctly show an unauthorized error when a user fails to login ([18925de](https://github.com/sct/overseerr/commit/18925decafdac518f52a354c594cc378d2529022)), closes [#322](https://github.com/sct/overseerr/issues/322)
* **frontend:** fix tv shows failing to open when firstAirDate is undefined ([c21fa5b](https://github.com/sct/overseerr/commit/c21fa5b5350abdd8e03c077fde7246fa398e176e)), closes [#347](https://github.com/sct/overseerr/issues/347)
* **frontend:** make minimum availability required for Radarr servers ([2fe53ec](https://github.com/sct/overseerr/commit/2fe53ec5a8534e75c7d0cef31a8b46065111e0a7)), closes [#345](https://github.com/sct/overseerr/issues/345)
* **plex-sync:** bundle duplicate ratingKeys to speed up recently added sync ([67146c3](https://github.com/sct/overseerr/commit/67146c33ef7f28d520ba2c50b32673d43f4525c8)), closes [#360](https://github.com/sct/overseerr/issues/360)
* **sonarr.ts, mediarequest.ts:** add missing seasonFolder option ([#358](https://github.com/sct/overseerr/issues/358)) ([e9c899c](https://github.com/sct/overseerr/commit/e9c899ce419d149dde2ad9a0f7d5a2f2545b3ebf))
### Features
* **frontend:** show alert when there are no default radarr/sonarr servers ([0d088e0](https://github.com/sct/overseerr/commit/0d088e085e68d39455fda21d1fd08ebcaef2c06b)), closes [#344](https://github.com/sct/overseerr/issues/344)
# [1.6.0](https://github.com/sct/overseerr/compare/v1.5.0...v1.6.0) (2020-12-16)
### Bug Fixes
* **api:** accept the api key to perform actions on the api with X-API-Key header ([33f8831](https://github.com/sct/overseerr/commit/33f8831e880dc7fd3f69d951246cada5c6c0ffe7))
* **api:** filter out libraries that do not have any metadata agent or are not movie/show ([01c179f](https://github.com/sct/overseerr/commit/01c179f762e686a1e5a3d4dab3a5bea53425b575))
* **api:** only run recently added sync on enabled libraries ([e08fa35](https://github.com/sct/overseerr/commit/e08fa35548bb8644afa8df3124e6f9cc3a2c8f4a)), closes [#259](https://github.com/sct/overseerr/issues/259)
* **api:** set plex libraries to disabled if the name changes ([675060b](https://github.com/sct/overseerr/commit/675060bcdf23acbfd4de2900a65f95e74f4966a5)), closes [#324](https://github.com/sct/overseerr/issues/324)
* **frontend:** adds a tip to plex setup to clarify that syncing runs in the background ([df4ac83](https://github.com/sct/overseerr/commit/df4ac8361f82971ee845f3be217408a9123a0bf3)), closes [#325](https://github.com/sct/overseerr/issues/325)
* **frontend:** aligned movie and tv details ([#331](https://github.com/sct/overseerr/issues/331)) ([db0a5c4](https://github.com/sct/overseerr/commit/db0a5c44f678e76eee7f5582381016306d1f46a2))
* **frontend:** close sidebar when clicking outside ([#333](https://github.com/sct/overseerr/issues/333)) ([6d7907e](https://github.com/sct/overseerr/commit/6d7907e844a909993d185759d660632f55aeaa35))
* spelling mistake on the word 'requested' fixed ([#319](https://github.com/sct/overseerr/issues/319)) ([961d110](https://github.com/sct/overseerr/commit/961d1107208069a6fc820a1ba97ffda7336677cb))
### Features
* add version to startup logs ([2948f93](https://github.com/sct/overseerr/commit/2948f9360eb484d1d6c0740a840135ca97e7240a))
* **frontend:** temporary logs page to clear up confusion about it 404ing ([d9788c4](https://github.com/sct/overseerr/commit/d9788c4aa9f87e2eda3f7e3f1adc985f16039552)), closes [#272](https://github.com/sct/overseerr/issues/272)
* **lang:** add support for Spanish language ([6cd2049](https://github.com/sct/overseerr/commit/6cd20491d2a0ceb995c4744eeb92a6e2f57a4893))
* **lang:** Translations update from Weblate ([#291](https://github.com/sct/overseerr/issues/291)) ([fddbb3c](https://github.com/sct/overseerr/commit/fddbb3cdfe3d50b2835c248556139c769dc2b805))
# [1.5.0](https://github.com/sct/overseerr/compare/v1.4.0...v1.5.0) (2020-12-15)
### Bug Fixes
* **api:** require package.json directly so typescript doesnt compile it into dist folder ([b9faa64](https://github.com/sct/overseerr/commit/b9faa6486b35aa865019aa8af9d307531054bc1d))
* **frontend:** add validation for Radarr/Sonarr server name ([b5988f9](https://github.com/sct/overseerr/commit/b5988f9a5ff274e97f208c2726abe76c22c858ee))
* **frontend:** only show alpha notice to admins ([ff61895](https://github.com/sct/overseerr/commit/ff618956b5d9cf933d867ea979b612c3d8a6f30b))
* add support for ssl when connecting to plex ([3ba09d0](https://github.com/sct/overseerr/commit/3ba09d07eb0367c41603cd55e7ff41c66fb641c4)), closes [#275](https://github.com/sct/overseerr/issues/275)
* **services:** improve logging for when Radarr movie already exists ([#285](https://github.com/sct/overseerr/issues/285)) ([f998873](https://github.com/sct/overseerr/commit/f998873fc5669a547901f2733c9c785d744d27ca)), closes [#260](https://github.com/sct/overseerr/issues/260)
### Features
* **lang:** add i18n strings for new about page ([900827b](https://github.com/sct/overseerr/commit/900827be97845688e4bea72a8c5d9611a3e9d069))
* about page initial version ([3f2a04c](https://github.com/sct/overseerr/commit/3f2a04c881bf06b73a952181fa463af84454b0dd))
# [1.4.0](https://github.com/sct/overseerr/compare/v1.3.2...v1.4.0) (2020-12-15)
### Bug Fixes
* changing parameter name to use correct 'port' [#276](https://github.com/sct/overseerr/issues/276) ([#277](https://github.com/sct/overseerr/issues/277)) ([6d08b10](https://github.com/sct/overseerr/commit/6d08b108200177ca3068c852e60a0df75ce2232a))
* **services:** include radarr/sonarr baseUrl when adding media ([78af1a3](https://github.com/sct/overseerr/commit/78af1a3e6d00a5645a05e7bf3cf56a59439b6cc9))
### Features
* **lang:** Translations update from Weblate ([#240](https://github.com/sct/overseerr/issues/240)) ([e17c637](https://github.com/sct/overseerr/commit/e17c63748362b6a480693e003ef5eec614dcec43))
## [1.3.2](https://github.com/sct/overseerr/compare/v1.3.1...v1.3.2) (2020-12-14)
### Bug Fixes
* **frontend:** convert plex port to a number before posting to the api ([8cb05c4](https://github.com/sct/overseerr/commit/8cb05c413a15a4b74e37ece5e24367d115995b32))
* **frontend:** converts email smtp port to a number before posting to the api ([2098a2d](https://github.com/sct/overseerr/commit/2098a2d3d2981fd2ae54392aec3ef81327f2858e)), closes [#251](https://github.com/sct/overseerr/issues/251)
* **frontend:** encode special characters in search input to prevent crashing router ([15013d6](https://github.com/sct/overseerr/commit/15013d6c5dbff15704c7c30d261d68a265e7f2d7)), closes [#252](https://github.com/sct/overseerr/issues/252)
* **plex sync:** catch errors that occur during processMovie ([edbbccf](https://github.com/sct/overseerr/commit/edbbccf3ae623430294f1a5c3fd2728dbd42e555)), closes [#244](https://github.com/sct/overseerr/issues/244) [#246](https://github.com/sct/overseerr/issues/246) [#250](https://github.com/sct/overseerr/issues/250)
* **services:** improve logging for adding movies to Radarr ([6c1ee83](https://github.com/sct/overseerr/commit/6c1ee830a183f89bb1fe96a181a7d61684e23b22))
* **services:** radarr/sonarr will use the correct default server ([0658b79](https://github.com/sct/overseerr/commit/0658b7943e1ab25816db9da34d4c9ea808d9203d))
## [1.3.1](https://github.com/sct/overseerr/compare/v1.3.0...v1.3.1) (2020-12-14)
### Bug Fixes
* **frontend:** also convert activeProfileId to a number for radarr/sonarr submissions ([7bf924f](https://github.com/sct/overseerr/commit/7bf924f7e94a0e0834f41b4ec067ed277c652766))
* **frontend:** also convert ports to numbers when saving radarr/sonarr servers ([c53dc3b](https://github.com/sct/overseerr/commit/c53dc3b15da522c6e6ab76bbc9d15008a8a9fb9d))
* **frontend:** new radarr/sonarr ports will be converted to a number before posting ([92c9001](https://github.com/sct/overseerr/commit/92c9001c9d1f2cbd272a5897ea1157d2cadbce2d))
# [1.3.0](https://github.com/sct/overseerr/compare/v1.2.0...v1.3.0) (2020-12-14)

View File

@@ -33,7 +33,7 @@ You can also run the development environment in [Docker](https://www.docker.com/
- PRs with commits not following this standard will not be merged.
- Please make meaningful commits, or squash them
- Always rebase your commit to the latest `develop` branch. Do not merge develop into your branch.
- It is your responsbility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch.
- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch.
- You can create a Draft pull request early to get feedback on your work.
- Your code must be formatted correctly or the tests will fail.
- We use Prettier to format our codebase. It should auto run with a git hook, but its recommended to have a Prettier extension installed in your editor and have it format on save.

View File

@@ -12,6 +12,11 @@ RUN yarn cache clean
FROM node:12.18-alpine
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
RUN apk add tzdata
COPY . /app
WORKDIR /app
@@ -21,3 +26,5 @@ COPY --from=BUILD_IMAGE /app/.next ./.next
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
CMD yarn start
EXPOSE 5055

View File

@@ -16,7 +16,7 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-7-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-23-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@@ -44,7 +44,7 @@
- More notification types (Slack/Telegram/etc.).
- Issues system. This will allow users to report issues with content on your media server.
- Local user system (for those who don't use Plex).
- Compatiblity APIs (to work with existing tools in your system).
- Compatibility APIs (to work with existing tools in your system).
## Running Overseerr
@@ -54,7 +54,7 @@ Currently, Overseerr is only distributed through Docker images. If you have Dock
docker run -d \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-p 5055:3000 \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
sctx/overseerr
@@ -76,7 +76,7 @@ After running Overseerr for the first time, configure it by visiting the web UI
## API Documentation
Full API documentation will soon be published automatically and available outside of running the app. Currently, you can access the API docs by running Overseerr locally and visiting http://localhost:3000/api-docs
Full API documentation will soon be published automatically and available outside of running the app. Currently, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs
## Community
@@ -86,7 +86,13 @@ If you would like to chat with community members you can join the [Overseerr Dis
Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels.
## Contributors
## Contributing
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
@@ -101,13 +107,30 @@ Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_COND
<td align="center"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4" width="100px;" alt=""/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4" width="100px;" alt=""/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4" width="100px;" alt=""/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4" width="100px;" alt=""/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4" width="100px;" alt=""/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4" width="100px;" alt=""/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4" width="100px;" alt=""/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4" width="100px;" alt=""/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
## Contributing
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.

View File

@@ -5,7 +5,7 @@ services:
context: .
dockerfile: Dockerfile.local
ports:
- 3000:3000
- 5055:5055
volumes:
- .:/app:rw,cached
- /app/node_modules

View File

@@ -2,9 +2,11 @@ const devConfig = {
type: 'sqlite',
database: 'config/db/db.sqlite3',
synchronize: true,
migrationsRun: false,
logging: false,
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
cli: {
entitiesDir: 'server/entity',
migrationsDir: 'server/migration',
@@ -18,7 +20,8 @@ const prodConfig = {
logging: false,
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/**/*.js'],
migrationsRun: true,
migrationsRun: false,
subscribers: ['dist/subscriber/**/*.js'],
cli: {
entitiesDir: 'dist/entity',
migrationsDir: 'dist/migration',

View File

@@ -58,6 +58,9 @@ components:
applicationUrl:
type: string
example: https://os.example.com
defaultPermissions:
type: number
example: 32
PlexLibrary:
type: object
properties:
@@ -436,12 +439,7 @@ components:
spokenLanguages:
type: array
items:
type: object
properties:
iso_639_1:
type: string
name:
type: string
$ref: '#/components/schemas/SpokenLanguage'
status:
type: string
tagline:
@@ -592,6 +590,10 @@ components:
type: array
items:
$ref: '#/components/schemas/ProductionCompany'
spokenLanguages:
type: array
items:
$ref: '#/components/schemas/SpokenLanguage'
seasons:
type: array
items:
@@ -617,6 +619,10 @@ components:
$ref: '#/components/schemas/Crew'
externalIds:
$ref: '#/components/schemas/ExternalIds'
keywords:
type: array
items:
$ref: '#/components/schemas/Keyword'
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
MediaRequest:
@@ -643,7 +649,6 @@ components:
readOnly: true
requestedBy:
$ref: '#/components/schemas/User'
readOnly: true
modifiedBy:
anyOf:
- $ref: '#/components/schemas/User'
@@ -819,7 +824,9 @@ components:
authPass:
type: string
nullable: true
allowSelfSigned:
type: boolean
example: false
PersonDetail:
type: object
properties:
@@ -962,17 +969,43 @@ components:
type: string
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
Keyword:
type: object
properties:
id:
type: number
example: 1
name:
type: string
example: 'anime'
SpokenLanguage:
type: object
properties:
englishName:
type: string
example: 'English'
nullable: true
iso_639_1:
type: string
example: 'en'
name:
type: string
example: 'English'
securitySchemes:
cookieAuth:
type: apiKey
name: connect.sid
in: cookie
apiKey:
type: apiKey
in: header
name: X-Api-Key
paths:
/settings/main:
get:
summary: Returns main settings
description: Retreives all main settings in JSON format
description: Retrieves all main settings in JSON format
tags:
- settings
responses:
@@ -1000,6 +1033,19 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
/settings/main/regenerate:
get:
summary: Returns main settings with newly generated API Key
description: Retreives all main settings in JSON format with new API Key
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
/settings/plex:
get:
summary: Returns plex settings
@@ -1137,7 +1183,7 @@ paths:
/settings/radarr/test:
post:
summary: Test radarr configuration
description: Test if the provided Radarr congifuration values are valid. Returns profiles and root folders on success
description: Test if the provided Radarr configuration values are valid. Returns profiles and root folders on success
tags:
- settings
requestBody:
@@ -1281,7 +1327,7 @@ paths:
/settings/sonarr/test:
post:
summary: Test Sonarr configuration
description: Test if the provided Sonarr congifuration values are valid. Returns profiles and root folders on success
description: Test if the provided Sonarr configuration values are valid. Returns profiles and root folders on success
tags:
- settings
requestBody:
@@ -1384,7 +1430,7 @@ paths:
/settings/initialize:
get:
summary: Set the application as initialized
description: Sets the app as initalized and allows the user to navigate to pages other than the setup page
description: Sets the app as initialized and allows the user to navigate to pages other than the setup page
tags:
- settings
responses:
@@ -1447,6 +1493,21 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/NotificationEmailSettings'
/settings/notifications/email/test:
post:
summary: Test the provided email settings
description: Sends a test notification to the email agent
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NotificationEmailSettings'
responses:
'204':
description: Test notification attempted
/settings/notifications/discord:
get:
summary: Return current discord notification settings
@@ -1478,6 +1539,48 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/DiscordSettings'
/settings/notifications/discord/test:
post:
summary: Test the provided discord settings
description: Sends a test notification to the discord agent
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DiscordSettings'
responses:
'204':
description: Test notification attempted
/settings/about:
get:
summary: Return current about stats
description: Returns current server stats in JSON format
tags:
- settings
responses:
'200':
description: Returned about settings
content:
application/json:
schema:
type: object
properties:
version:
type: string
example: '1.0.0'
totalRequests:
type: number
example: 100
totalMediaItems:
type: number
example: 100
tz:
type: string
nullable: true
example: Asia/Tokyo
/auth/me:
get:
summary: Returns the currently logged in user
@@ -1572,6 +1675,24 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/User'
/user/import-from-plex:
post:
summary: Imports all users from Plex
description: |
Requests users from the Plex Server and creates a new user for each of them
Requires the `MANAGE_USERS` permission.
tags:
- users
responses:
'201':
description: A list of the newly created users
content:
application/json:
schema:
type: array
$ref: '#/components/schemas/User'
/user/{userId}:
get:
summary: Retrieve a user by ID
@@ -1844,6 +1965,51 @@ paths:
- $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult'
- $ref: '#/components/schemas/PersonResult'
/discover/keyword/{keywordId}/movies:
get:
summary: Request list of movies from keyword
description: Returns list of movies based on provided keyword ID in JSON format
tags:
- search
parameters:
- in: path
name: keywordId
required: true
schema:
type: number
example: 207317
- in: query
name: page
schema:
type: number
example: 1
default: 1
- in: query
name: language
schema:
type: string
example: en
responses:
'200':
description: List of movies
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/MovieResult'
/request:
get:
summary: Get all requests
@@ -2463,3 +2629,4 @@ paths:
security:
- cookieAuth: []
- apiKey: []

View File

@@ -1,6 +1,6 @@
{
"name": "overseerr",
"version": "1.3.0",
"version": "1.13.0",
"private": true,
"scripts": {
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
@@ -11,6 +11,7 @@
"start": "NODE_ENV=production node dist/index.js",
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false './src/**/!(*.test).{ts,tsx}'",
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate",
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create",
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run",
"format": "prettier --write ."
},
@@ -24,29 +25,31 @@
"cookie-parser": "^1.4.5",
"email-templates": "^8.0.2",
"express": "^4.17.1",
"express-openapi-validator": "^4.8.0",
"express-openapi-validator": "^4.9.4",
"express-session": "^1.17.1",
"formik": "^2.2.5",
"formik": "^2.2.6",
"intl": "^1.2.5",
"lodash": "^4.17.20",
"next": "^10.0.3",
"node-schedule": "^1.3.2",
"nodemailer": "^6.4.16",
"nodemailer": "^6.4.17",
"nookies": "^2.5.0",
"plex-api": "^5.3.1",
"pug": "^3.0.0",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-intersection-observer": "^8.31.0",
"react-intl": "^5.10.6",
"react-intl": "^5.10.9",
"react-markdown": "^5.0.3",
"react-spring": "^8.0.27",
"react-toast-notifications": "^2.4.0",
"react-transition-group": "^4.4.1",
"react-truncate-markup": "^5.0.1",
"react-use-clipboard": "1.0.2",
"reflect-metadata": "^0.1.13",
"sqlite3": "^5.0.0",
"swagger-ui-express": "^4.1.5",
"swr": "^0.3.9",
"swr": "^0.3.11",
"typeorm": "^0.2.29",
"uuid": "^8.3.2",
"winston": "^3.3.3",
@@ -55,7 +58,7 @@
"yup": "^0.32.8"
},
"devDependencies": {
"@babel/cli": "^7.12.8",
"@babel/cli": "^7.12.10",
"@commitlint/cli": "^11.0.0",
"@commitlint/config-conventional": "^11.0.0",
"@semantic-release/changelog": "^5.0.1",
@@ -71,7 +74,7 @@
"@types/express": "^4.17.9",
"@types/express-session": "^1.17.0",
"@types/lodash": "^4.14.165",
"@types/node": "^14.14.11",
"@types/node": "^14.14.14",
"@types/node-schedule": "^1.3.1",
"@types/nodemailer": "^6.4.0",
"@types/react": "^17.0.0",
@@ -82,24 +85,24 @@
"@types/uuid": "^8.3.0",
"@types/xml2js": "^0.4.7",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.10",
"@typescript-eslint/eslint-plugin": "^4.9.1",
"@typescript-eslint/parser": "^4.9.1",
"@types/yup": "^0.29.11",
"@typescript-eslint/eslint-plugin": "^4.10.0",
"@typescript-eslint/parser": "^4.10.0",
"autoprefixer": "^9",
"babel-plugin-react-intl": "^8.2.21",
"babel-plugin-react-intl": "^8.2.22",
"babel-plugin-react-intl-auto": "^3.3.0",
"commitizen": "^4.2.2",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.15.0",
"eslint-config-prettier": "^7.0.0",
"eslint-plugin-formatjs": "^2.9.10",
"eslint": "^7.16.0",
"eslint-config-prettier": "^7.1.0",
"eslint-plugin-formatjs": "^2.9.11",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.2.0",
"eslint-plugin-prettier": "^3.3.0",
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-react-hooks": "^4.2.0",
"extract-react-intl-messages": "^4.1.1",
"husky": "^4.3.5",
"husky": "^4.3.6",
"lint-staged": "^10.5.3",
"nodemon": "^2.0.6",
"postcss": "^7",
@@ -109,7 +112,7 @@
"semantic-release-docker": "^2.2.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
"ts-node": "^9.1.1",
"typescript": "^4.1.2"
"typescript": "^4.1.3"
},
"config": {
"commitizen": {
@@ -170,7 +173,13 @@
"prepareCmd": "docker build -t sctx/overseerr ."
}
],
"semantic-release-docker"
"semantic-release-docker",
[
"@semantic-release/github",
{
"addReleases": "bottom"
}
]
],
"branches": [
"master"
@@ -180,7 +189,8 @@
{
"path": "semantic-release-docker",
"name": "sctx/overseerr"
}
},
"@semantic-release/github"
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1 +1,20 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{
"name": "Overseerr",
"short_name": "Overseerr",
"start_url": "./",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#1e2937",
"display": "standalone"
}

View File

@@ -4,10 +4,12 @@ import { getSettings } from '../lib/settings';
export interface PlexLibraryItem {
ratingKey: string;
parentRatingKey?: string;
grandparentRatingKey?: string;
title: string;
guid: string;
parentGuid?: string;
type: 'movie' | 'show' | 'season';
grandparentGuid?: string;
type: 'movie' | 'show' | 'season' | 'episode';
}
interface PlexLibraryResponse {
@@ -20,6 +22,7 @@ export interface PlexLibrary {
type: 'show' | 'movie';
key: string;
title: string;
agent: string;
}
interface PlexLibrariesResponse {
@@ -61,7 +64,8 @@ class PlexAPI {
this.plexClient = new NodePlexAPI({
hostname: settings.plex.ip,
post: settings.plex.port,
port: settings.plex.port,
https: settings.plex.useSsl,
token: plexToken,
authenticator: {
authenticate: (
@@ -119,9 +123,9 @@ class PlexAPI {
return response.MediaContainer.Metadata[0];
}
public async getRecentlyAdded(): Promise<PlexLibraryItem[]> {
public async getRecentlyAdded(id: string): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query<PlexLibraryResponse>(
'/library/recentlyAdded'
`/library/sections/${id}/recentlyAdded`
);
return response.MediaContainer.Metadata;

View File

@@ -56,6 +56,21 @@ interface FriendResponse {
};
}
interface UsersResponse {
MediaContainer: {
User: {
$: {
id: string;
title: string;
username: string;
email: string;
thumb: string;
};
Server: ServerResponse[];
}[];
};
}
class PlexTvAPI {
private authToken: string;
private axios: AxiosInstance;
@@ -129,6 +144,18 @@ class PlexTvAPI {
return false;
}
}
public async getUsers(): Promise<UsersResponse> {
const response = await this.axios.get('/api/users', {
transformResponse: [],
responseType: 'text',
});
const parsedXml = (await xml2js.parseStringPromise(
response.data
)) as UsersResponse;
return parsedXml;
}
}
export default PlexTvAPI;

View File

@@ -1,4 +1,5 @@
import Axios, { AxiosInstance } from 'axios';
import logger from '../logger';
interface RadarrMovieOptions {
title: string;
@@ -75,9 +76,7 @@ class RadarrAPI {
}
};
public addMovie = async (
options: RadarrMovieOptions
): Promise<RadarrMovie> => {
public addMovie = async (options: RadarrMovieOptions): Promise<void> => {
try {
const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title,
@@ -94,9 +93,27 @@ class RadarrAPI {
},
});
return response.data;
if (response.data.id) {
logger.info('Radarr accepted request', { label: 'Radarr' });
logger.debug('Radarr add details', {
label: 'Radarr',
movie: response.data,
});
} else {
logger.error('Failed to add movie to Radarr', {
label: 'Radarr',
options,
});
}
} catch (e) {
throw new Error(`[Radarr] Failed to add movie: ${e.message}`);
logger.error(
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
{
label: 'Radarr',
errorMessage: e.message,
options,
}
);
}
};

View File

@@ -116,7 +116,7 @@ class RottenTomatoes {
public async getTVRatings(
name: string,
year: number
year?: number
): Promise<RTRating | null> {
try {
const response = await this.axios.get<RTMultiSearchResponse>(
@@ -126,9 +126,13 @@ class RottenTomatoes {
}
);
const tvshow = response.data.tvSeries.find(
(series) => series.startYear === year
);
let tvshow: RTTvSearchResult | undefined = response.data.tvSeries[0];
if (year) {
tvshow = response.data.tvSeries.find(
(series) => series.startYear === year
);
}
if (!tvshow) {
return null;

View File

@@ -6,7 +6,7 @@ interface SonarrSeason {
monitored: boolean;
}
interface SonarrSeries {
export interface SonarrSeries {
title: string;
sortTitle: string;
seasonCount: number;
@@ -33,7 +33,7 @@ interface SonarrSeries {
tvMazeId: number;
firstAired: string;
lastInfoSync?: string;
seriesType: string;
seriesType: 'standard' | 'daily' | 'anime';
cleanTitle: string;
imdbId: string;
titleSlug: string;
@@ -76,7 +76,9 @@ interface AddSeriesOptions {
title: string;
profileId: number;
seasons: number[];
seasonFolder: boolean;
rootFolderPath: string;
seriesType: SonarrSeries['seriesType'];
monitored?: boolean;
searchNow?: boolean;
}
@@ -124,7 +126,7 @@ class SonarrAPI {
series.addOptions = {
ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: true,
searchForMissingEpisodes: options.searchNow,
};
const newSeriesResponse = await this.axios.put<SonarrSeries>(
@@ -132,6 +134,21 @@ class SonarrAPI {
series
);
if (newSeriesResponse.data.id) {
logger.info('Sonarr accepted request. Updated existing series', {
label: 'Sonarr',
});
logger.debug('Sonarr add details', {
label: 'Sonarr',
movie: newSeriesResponse.data,
});
} else {
logger.error('Failed to add movie to Sonarr', {
label: 'Sonarr',
options,
});
}
return newSeriesResponse.data;
}
@@ -149,8 +166,10 @@ class SonarrAPI {
monitored: false,
}))
),
seasonFolder: options.seasonFolder,
monitored: options.monitored,
rootFolderPath: options.rootFolderPath,
seriesType: options.seriesType,
addOptions: {
ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: options.searchNow,
@@ -158,11 +177,24 @@ class SonarrAPI {
} as Partial<SonarrSeries>
);
if (createdSeriesResponse.data.id) {
logger.info('Sonarr accepted request', { label: 'Sonarr' });
logger.debug('Sonarr add details', {
label: 'Sonarr',
movie: createdSeriesResponse.data,
});
} else {
logger.error('Failed to add movie to Sonarr', {
label: 'Sonarr',
options,
});
}
return createdSeriesResponse.data;
} catch (e) {
logger.error('Something went wrong adding a series to Sonarr', {
label: 'Sonarr API',
message: e.message,
errorMessage: e.message,
error: e,
});
throw new Error('Failed to add series');

View File

@@ -1,5 +1,7 @@
import axios, { AxiosInstance } from 'axios';
export const ANIME_KEYWORD_ID = 210024;
interface SearchOptions {
query: string;
page?: number;
@@ -69,7 +71,7 @@ export interface TmdbTvResult extends TmdbMediaResult {
name: string;
original_name: string;
origin_country: string[];
first_air_Date: string;
first_air_date: string;
}
export interface TmdbPersonResult {
@@ -258,6 +260,11 @@ export interface TmdbTvDetails {
name: string;
origin_country: string;
}[];
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
seasons: TmdbTvSeasonResult[];
status: string;
type: string;
@@ -268,6 +275,14 @@ export interface TmdbTvDetails {
crew: TmdbCreditCrew[];
};
external_ids: TmdbExternalIds;
keywords: {
results: TmdbKeyword[];
};
}
export interface TmdbKeyword {
id: number;
name: string;
}
export interface TmdbPersonDetail {
@@ -359,7 +374,12 @@ class TheMovieDb {
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to search multi: ${e.message}`);
return {
page: 1,
results: [],
total_pages: 1,
total_results: 0,
};
}
};
@@ -437,7 +457,10 @@ class TheMovieDb {
}): Promise<TmdbTvDetails> => {
try {
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
params: { language, append_to_response: 'credits,external_ids' },
params: {
language,
append_to_response: 'credits,external_ids,keywords',
},
});
return response.data;
@@ -524,6 +547,32 @@ class TheMovieDb {
}
}
public async getMoviesByKeyword({
keywordId,
page = 1,
language = 'en-US',
}: {
keywordId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
}
}
public async getTvRecommendations({
tvId,
page = 1,

View File

@@ -8,14 +8,11 @@ import {
UpdateDateColumn,
getRepository,
In,
AfterUpdate,
} from 'typeorm';
import { MediaRequest } from './MediaRequest';
import { MediaStatus, MediaType } from '../constants/media';
import logger from '../logger';
import Season from './Season';
import notificationManager, { Notification } from '../lib/notifications';
import TheMovieDb from '../api/themoviedb';
@Entity()
class Media {
@@ -95,35 +92,12 @@ class Media {
@UpdateDateColumn()
public updatedAt: Date;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public lastSeasonChange: Date;
constructor(init?: Partial<Media>) {
Object.assign(this, init);
}
@AfterUpdate()
private async _notifyAvailable() {
if (this.status === MediaStatus.AVAILABLE) {
if (this.mediaType === MediaType.MOVIE) {
const requestRepository = getRepository(MediaRequest);
const relatedRequests = await requestRepository.find({
where: { media: this },
});
if (relatedRequests.length > 0) {
const tmdb = new TheMovieDb();
const movie = await tmdb.getMovie({ movieId: this.tmdbId });
relatedRequests.forEach((request) => {
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
notifyUser: request.requestedBy,
subject: movie.title,
message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
});
}
}
}
}
}
export default Media;

View File

@@ -15,11 +15,11 @@ import { User } from './User';
import Media from './Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
import TheMovieDb from '../api/themoviedb';
import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
import RadarrAPI from '../api/radarr';
import logger from '../logger';
import SeasonRequest from './SeasonRequest';
import SonarrAPI from '../api/sonarr';
import SonarrAPI, { SonarrSeries } from '../api/sonarr';
import notificationManager, { Notification } from '../lib/notifications';
@Entity()
@@ -36,10 +36,18 @@ export class MediaRequest {
})
public media: Media;
@ManyToOne(() => User, (user) => user.requests, { eager: true })
@ManyToOne(() => User, (user) => user.requests, {
eager: true,
onDelete: 'CASCADE',
})
public requestedBy: User;
@ManyToOne(() => User, { nullable: true, cascade: true, eager: true })
@ManyToOne(() => User, {
nullable: true,
cascade: true,
eager: true,
onDelete: 'SET NULL',
})
public modifiedBy?: User;
@CreateDateColumn()
@@ -80,6 +88,7 @@ export class MediaRequest {
message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
media,
});
}
@@ -90,6 +99,7 @@ export class MediaRequest {
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
media,
extra: [
{
name: 'Seasons',
@@ -128,6 +138,7 @@ export class MediaRequest {
message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
media,
});
} else if (this.media.mediaType === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
@@ -136,6 +147,7 @@ export class MediaRequest {
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
media,
extra: [
{
name: 'Seasons',
@@ -234,13 +246,24 @@ export class MediaRequest {
return;
}
const radarrSettings = settings.radarr.find(
(radarr) => radarr.isDefault && !radarr.is4k
);
if (!radarrSettings) {
logger.info(
'There is no default radarr configured. Did you set any of your Radarr servers as default?',
{ label: 'Media Request' }
);
return;
}
const tmdb = new TheMovieDb();
const radarrSettings = settings.radarr[0];
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}/api`,
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
});
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
@@ -283,6 +306,18 @@ export class MediaRequest {
return;
}
const sonarrSettings = settings.sonarr.find(
(sonarr) => sonarr.isDefault && !sonarr.is4k
);
if (!sonarrSettings) {
logger.info(
'There is no default sonarr configured. Did you set any of your Sonarr servers as default?',
{ label: 'Media Request' }
);
return;
}
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: ['requests'],
@@ -293,12 +328,11 @@ export class MediaRequest {
}
const tmdb = new TheMovieDb();
const sonarrSettings = settings.sonarr[0];
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}/api`,
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
@@ -306,13 +340,32 @@ export class MediaRequest {
throw new Error('Series was missing tvdb id');
}
let seriesType: SonarrSeries['seriesType'] = 'standard';
// Change series type to anime if the anime keyword is present on tmdb
if (
series.keywords.results.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
)
) {
seriesType = 'anime';
}
// Run this asynchronously so we don't wait for it on the UI side
sonarr.addSeries({
profileId: sonarrSettings.activeProfileId,
rootFolderPath: sonarrSettings.activeDirectory,
profileId:
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId,
rootFolderPath:
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
? sonarrSettings.activeAnimeDirectory
: sonarrSettings.activeDirectory,
title: series.name,
tvdbid: series.external_ids.tvdb_id,
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
monitored: true,
searchNow: true,
});

View File

@@ -5,15 +5,9 @@ import {
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
AfterInsert,
AfterUpdate,
getRepository,
} from 'typeorm';
import { MediaStatus } from '../constants/media';
import Media from './Media';
import logger from '../logger';
import TheMovieDb from '../api/themoviedb';
import notificationManager, { Notification } from '../lib/notifications';
@Entity()
class Season {
@@ -38,60 +32,6 @@ class Season {
constructor(init?: Partial<Season>) {
Object.assign(this, init);
}
@AfterInsert()
@AfterUpdate()
private async _sendSeasonAvailableNotification() {
if (this.status === MediaStatus.AVAILABLE) {
try {
const lazyMedia = await this.media;
const tmdb = new TheMovieDb();
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOneOrFail({
where: { id: lazyMedia.id },
relations: ['requests'],
});
const availableSeasons = media.seasons.map(
(season) => season.seasonNumber
);
const request = media.requests.find(
(request) =>
// Check if the season is complete AND it contains the current season that was just marked available
request.seasons.every((season) =>
availableSeasons.includes(season.seasonNumber)
) &&
request.seasons.some(
(season) => season.seasonNumber === this.seasonNumber
)
);
if (request) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
subject: tv.name,
message: tv.overview,
notifyUser: request.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
extra: [
{
name: 'Seasons',
value: request.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
}
} catch (e) {
logger.error('Something went wrong sending season available notice', {
label: 'Notifications',
message: e.message,
});
}
}
}
}
export default Season;

View File

@@ -17,9 +17,11 @@ import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email';
import { getAppVersion } from './utils/appVersion';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Overseerr version ${getAppVersion()}`);
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
@@ -27,7 +29,15 @@ const handle = app.getRequestHandler();
app
.prepare()
.then(async () => {
await createConnection();
const dbConnection = await createConnection();
// Run migrations in production
if (process.env.NODE_ENV === 'production') {
await dbConnection.query('PRAGMA foreign_keys=OFF');
await dbConnection.runMigrations();
await dbConnection.query('PRAGMA foreign_keys=ON');
}
// Load Settings
const settings = getSettings().load();
@@ -98,12 +108,21 @@ app
}
);
const port = Number(process.env.PORT) || 3000;
server.listen(port, () => {
logger.info(`Server ready on port ${port}`, {
label: 'SERVER',
const port = Number(process.env.PORT) || 5055;
const host = process.env.HOST;
if (host) {
server.listen(port, host, () => {
logger.info(`Server ready on ${host} port ${port}`, {
label: 'Server',
});
});
});
} else {
server.listen(port, () => {
logger.info(`Server ready on port ${port}`, {
label: 'Server',
});
});
}
})
.catch((err) => {
logger.error(err.stack);

View File

@@ -0,0 +1,6 @@
export interface SettingsAboutResponse {
version: string;
totalRequests: number;
totalMediaItems: number;
tz?: string;
}

View File

@@ -1,18 +1,23 @@
import { getRepository } from 'typeorm';
import { User } from '../../entity/User';
import PlexAPI, { PlexLibraryItem } from '../../api/plexapi';
import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb';
import TheMovieDb, {
TmdbMovieDetails,
TmdbTvDetails,
} from '../../api/themoviedb';
import Media from '../../entity/Media';
import { MediaStatus, MediaType } from '../../constants/media';
import logger from '../../logger';
import { getSettings, Library } from '../../lib/settings';
import Season from '../../entity/Season';
import { uniqWith } from 'lodash';
const BUNDLE_SIZE = 10;
const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000;
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)|hama:\/\/tvdb-([0-9]+)/);
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
@@ -51,45 +56,66 @@ class JobPlexSync {
private async processMovie(plexitem: PlexLibraryItem) {
const mediaRepository = getRepository(Media);
if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
const newMedia = new Media();
try {
if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
const newMedia = new Media();
metadata.Guid.forEach((ref) => {
if (ref.id.match(imdbRegex)) {
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
} else if (ref.id.match(tmdbRegex)) {
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
newMedia.tmdbId = Number(tmdbMatch);
if (!metadata.Guid) {
logger.debug('No Guid metadata for this title. Skipping', {
label: 'Plex Sync',
ratingKey: plexitem.ratingKey,
});
return;
}
});
const existing = await this.getExisting(newMedia.tmdbId);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${metadata.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
existing.status = MediaStatus.AVAILABLE;
mediaRepository.save(existing);
this.log(
`Request for ${metadata.title} exists. Setting status AVAILABLE`,
'info'
);
} else {
newMedia.status = MediaStatus.AVAILABLE;
newMedia.mediaType = MediaType.MOVIE;
await mediaRepository.save(newMedia);
this.log(`Saved ${plexitem.title}`);
}
} else {
const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/);
if (matchedid?.[1]) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: matchedid[1],
metadata.Guid.forEach((ref) => {
if (ref.id.match(imdbRegex)) {
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
} else if (ref.id.match(tmdbRegex)) {
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
newMedia.tmdbId = Number(tmdbMatch);
}
});
const existing = await this.getExisting(tmdbMovie.id);
const existing = await this.getExisting(newMedia.tmdbId);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${metadata.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
existing.status = MediaStatus.AVAILABLE;
mediaRepository.save(existing);
this.log(
`Request for ${metadata.title} exists. Setting status AVAILABLE`,
'info'
);
} else {
newMedia.status = MediaStatus.AVAILABLE;
newMedia.mediaType = MediaType.MOVIE;
await mediaRepository.save(newMedia);
this.log(`Saved ${plexitem.title}`);
}
} else {
let tmdbMovieId: number | undefined;
let tmdbMovie: TmdbMovieDetails | undefined;
const imdbMatch = plexitem.guid.match(imdbRegex);
const tmdbMatch = plexitem.guid.match(tmdbShowRegex);
if (imdbMatch) {
tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: imdbMatch[1],
});
tmdbMovieId = tmdbMovie.id;
} else if (tmdbMatch) {
tmdbMovieId = Number(tmdbMatch[1]);
}
if (!tmdbMovieId) {
throw new Error('Unable to find TMDB ID');
}
const existing = await this.getExisting(tmdbMovieId);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${plexitem.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
@@ -99,7 +125,12 @@ class JobPlexSync {
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
'info'
);
} else if (tmdbMovie) {
} else {
// If we have a tmdb movie guid but it didn't already exist, only then
// do we request the movie from tmdb (to reduce api requests)
if (!tmdbMovie) {
tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId });
}
const newMedia = new Media();
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
newMedia.tmdbId = tmdbMovie.id;
@@ -109,6 +140,15 @@ class JobPlexSync {
this.log(`Saved ${tmdbMovie.title}`);
}
}
} catch (e) {
this.log(
`Failed to process plex item. ratingKey: ${plexitem.ratingKey}`,
'error',
{
errorMessage: e.message,
plexitem,
}
);
}
}
@@ -119,7 +159,9 @@ class JobPlexSync {
try {
const metadata = await this.plexClient.getMetadata(
plexitem.parentRatingKey ?? plexitem.ratingKey,
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey,
{ includeChildren: true }
);
if (metadata.guid.match(tvdbRegex)) {
@@ -148,6 +190,12 @@ class JobPlexSync {
const newSeasons: Season[] = [];
const currentSeasonAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
seasons.forEach((season) => {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
@@ -198,6 +246,25 @@ class JobPlexSync {
if (media) {
// Update existing
media.seasons = [...media.seasons, ...newSeasons];
const newSeasonAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newSeasonAvailable > currentSeasonAvailable) {
this.log(
`Detected ${
newSeasonAvailable - currentSeasonAvailable
} new season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
media.status = isAllSeasons
? MediaStatus.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE;
@@ -222,9 +289,15 @@ class JobPlexSync {
} catch (e) {
this.log(
`Failed to process plex item. ratingKey: ${
plexitem.parentRatingKey ?? plexitem.ratingKey
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey
}`,
'error'
'error',
{
errorMessage: e.message,
plexitem,
}
);
}
}
@@ -234,7 +307,11 @@ class JobPlexSync {
slicedItems.map(async (plexitem) => {
if (plexitem.type === 'movie') {
await this.processMovie(plexitem);
} else if (plexitem.type === 'show') {
} else if (
plexitem.type === 'show' ||
plexitem.type === 'episode' ||
plexitem.type === 'season'
) {
await this.processShow(plexitem);
}
})
@@ -260,16 +337,17 @@ class JobPlexSync {
end: end + BUNDLE_SIZE,
});
resolve();
}, 5000)
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' = 'debug'
level: 'info' | 'error' | 'debug' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Plex Sync' });
logger[level](message, { label: 'Plex Sync', ...optional });
}
public async run(): Promise<void> {
@@ -283,20 +361,40 @@ class JobPlexSync {
});
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
if (this.isRecentOnly) {
this.currentLibrary = {
id: '0',
name: 'Recently Added',
enabled: true,
};
this.log(`Beginning to process recently added`, 'info');
this.items = await this.plexClient.getRecentlyAdded();
await this.loop();
} else {
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
if (this.isRecentOnly) {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(
`Beginning to process recently added for library: ${library.name}`,
'info'
);
const libraryItems = await this.plexClient.getRecentlyAdded(
library.id
);
// Bundle items up by rating keys
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
return (
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
);
}
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
return mediaA.parentRatingKey === mediaB.parentRatingKey;
}
return mediaA.ratingKey === mediaB.ratingKey;
});
await this.loop();
}
} else {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');

View File

@@ -13,17 +13,17 @@ export const startJobs = (): void => {
// Run recently added plex sync every 5 minutes
scheduledJobs.push({
name: 'Plex Recently Added Sync',
job: schedule.scheduleJob('0 */10 * * * *', () => {
job: schedule.scheduleJob('0 */5 * * * *', () => {
logger.info('Starting scheduled job: Plex Recently Added Sync', {
label: 'Jobs',
});
jobPlexRecentSync.run();
}),
});
// Run full plex sync every 6 hours
// Run full plex sync every 24 hours
scheduledJobs.push({
name: 'Plex Full Library Sync',
job: schedule.scheduleJob('0 0 */6 * * *', () => {
job: schedule.scheduleJob('0 0 3 * * *', () => {
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
jobPlexFullSync.run();
}),

View File

@@ -1,14 +1,26 @@
import { Notification } from '..';
import Media from '../../../entity/Media';
import { User } from '../../../entity/User';
import { NotificationAgentConfig } from '../../settings';
export interface NotificationPayload {
subject: string;
notifyUser: User;
media?: Media;
image?: string;
message?: string;
extra?: { name: string; value: string }[];
}
export abstract class BaseAgent<T extends NotificationAgentConfig> {
protected settings?: T;
public constructor(settings?: T) {
this.settings = settings;
}
protected abstract getSettings(): T;
}
export interface NotificationAgent {
shouldSend(type: Notification): boolean;
send(type: Notification, payload: NotificationPayload): Promise<boolean>;

View File

@@ -1,8 +1,8 @@
import axios from 'axios';
import { Notification } from '..';
import logger from '../../../logger';
import { getSettings } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { getSettings, NotificationAgentDiscord } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
enum EmbedColors {
DEFAULT = 0,
@@ -37,6 +37,11 @@ interface DiscordImageEmbed {
width?: number;
}
interface Field {
name: string;
value: string;
inline?: boolean;
}
interface DiscordRichEmbed {
title?: string;
type?: 'rich'; // Always rich for webhooks
@@ -61,11 +66,7 @@ interface DiscordRichEmbed {
icon_url?: string;
proxy_icon_url?: string;
};
fields?: {
name: string;
value: string;
inline?: boolean;
}[];
fields?: Field[];
}
interface DiscordWebhookPayload {
@@ -75,26 +76,94 @@ interface DiscordWebhookPayload {
tts: boolean;
}
class DiscordAgent implements NotificationAgent {
class DiscordAgent
extends BaseAgent<NotificationAgentDiscord>
implements NotificationAgent {
protected getSettings(): NotificationAgentDiscord {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.discord;
}
public buildEmbed(
type: Notification,
payload: NotificationPayload
): DiscordRichEmbed {
let color = EmbedColors.DEFAULT;
let status = 'Unknown';
const settings = getSettings();
let color = EmbedColors.DARK_PURPLE;
const fields: Field[] = [];
switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
status = 'Pending Approval';
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: 'Pending Approval',
inline: true,
}
);
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
break;
case Notification.MEDIA_APPROVED:
color = EmbedColors.PURPLE;
status = 'Processing Request';
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: 'Processing Request',
inline: true,
}
);
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
status = 'Available';
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: 'Available',
inline: true,
}
);
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
break;
}
@@ -103,18 +172,9 @@ class DiscordAgent implements NotificationAgent {
description: payload.message,
color,
timestamp: new Date().toISOString(),
author: { name: 'Overseerr' },
author: { name: 'Overseerr', url: settings.main.applicationUrl },
fields: [
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: status,
inline: true,
},
...fields,
// If we have extra data, map it to fields for discord notifications
...(payload.extra ?? []).map((extra) => ({
name: extra.name,
@@ -130,12 +190,7 @@ class DiscordAgent implements NotificationAgent {
// TODO: Add checking for type here once we add notification type filters for agents
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
const settings = getSettings();
if (
settings.notifications.agents.discord?.enabled &&
settings.notifications.agents.discord?.options?.webhookUrl
) {
if (this.getSettings().enabled && this.getSettings().options.webhookUrl) {
return true;
}
@@ -146,11 +201,9 @@ class DiscordAgent implements NotificationAgent {
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = getSettings();
logger.debug('Sending discord notification', { label: 'Notifications' });
try {
const webhookUrl =
settings.notifications.agents.discord?.options?.webhookUrl;
const webhookUrl = this.getSettings().options.webhookUrl;
if (!webhookUrl) {
return false;

View File

@@ -1,7 +1,7 @@
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { Notification } from '..';
import path from 'path';
import { getSettings } from '../../settings';
import { getSettings, NotificationAgentEmail } from '../../settings';
import nodemailer from 'nodemailer';
import Email from 'email-templates';
import logger from '../../../logger';
@@ -9,13 +9,25 @@ import { getRepository } from 'typeorm';
import { User } from '../../../entity/User';
import { Permission } from '../../permissions';
class EmailAgent implements NotificationAgent {
class EmailAgent
extends BaseAgent<NotificationAgentEmail>
implements NotificationAgent {
protected getSettings(): NotificationAgentEmail {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.email;
}
// TODO: Add checking for type here once we add notification type filters for agents
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
const settings = getSettings();
const settings = this.getSettings();
if (settings.notifications.agents.email.enabled) {
if (settings.enabled) {
return true;
}
@@ -23,21 +35,29 @@ class EmailAgent implements NotificationAgent {
}
private getSmtpTransport() {
const emailSettings = getSettings().notifications.agents.email.options;
const emailSettings = this.getSettings().options;
return nodemailer.createTransport({
host: emailSettings.smtpHost,
port: emailSettings.smtpPort,
secure: emailSettings.secure,
auth: {
user: emailSettings.authUser,
pass: emailSettings.authPass,
},
tls: emailSettings.allowSelfSigned
? {
rejectUnauthorized: false,
}
: undefined,
auth:
emailSettings.authUser && emailSettings.authPass
? {
user: emailSettings.authUser,
pass: emailSettings.authPass,
}
: undefined,
});
}
private getNewEmail() {
const settings = getSettings().notifications.agents.email;
const settings = this.getSettings();
return new Email({
message: {
from: settings.options.emailFrom,
@@ -48,7 +68,8 @@ class EmailAgent implements NotificationAgent {
}
private async sendMediaRequestEmail(payload: NotificationPayload) {
const settings = getSettings().main;
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
@@ -73,7 +94,10 @@ class EmailAgent implements NotificationAgent {
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
actionUrl: settings.applicationUrl,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
requestType: 'New Request',
},
});
@@ -89,11 +113,12 @@ class EmailAgent implements NotificationAgent {
}
private async sendMediaApprovedEmail(payload: NotificationPayload) {
const settings = getSettings().main;
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
try {
const email = this.getNewEmail();
email.send({
await email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
@@ -107,7 +132,10 @@ class EmailAgent implements NotificationAgent {
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
actionUrl: settings.applicationUrl,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
requestType: 'Request Approved',
},
});
@@ -122,11 +150,12 @@ class EmailAgent implements NotificationAgent {
}
private async sendMediaAvailableEmail(payload: NotificationPayload) {
const settings = getSettings().main;
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
try {
const email = this.getNewEmail();
email.send({
await email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
@@ -135,12 +164,15 @@ class EmailAgent implements NotificationAgent {
to: payload.notifyUser.email,
},
locals: {
body: 'Your requsested media is now available!',
body: 'Your requested media is now available!',
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
actionUrl: settings.applicationUrl,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
requestType: 'Now Available',
},
});
@@ -154,6 +186,32 @@ class EmailAgent implements NotificationAgent {
}
}
private async sendTestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
try {
const email = this.getNewEmail();
await email.send({
template: path.join(__dirname, '../../../templates/email/test-email'),
message: {
to: payload.notifyUser.email,
},
locals: {
body: payload.message,
applicationUrl,
},
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
public async send(
type: Notification,
payload: NotificationPayload
@@ -170,6 +228,9 @@ class EmailAgent implements NotificationAgent {
case Notification.MEDIA_AVAILABLE:
this.sendMediaAvailableEmail(payload);
break;
case Notification.TEST_NOTIFICATION:
this.sendTestEmail(payload);
break;
}
return true;

View File

@@ -5,6 +5,7 @@ export enum Notification {
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
TEST_NOTIFICATION = 16,
}
class NotificationManager {

View File

@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { merge } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Permission } from './permissions';
export interface Library {
id: string;
@@ -14,6 +15,7 @@ export interface PlexSettings {
machineId?: string;
ip: string;
port: number;
useSsl?: boolean;
libraries: Library[];
}
@@ -46,24 +48,25 @@ export interface SonarrSettings extends DVRSettings {
export interface MainSettings {
apiKey: string;
applicationUrl: string;
defaultPermissions: number;
}
interface PublicSettings {
initialized: boolean;
}
interface NotificationAgent {
export interface NotificationAgentConfig {
enabled: boolean;
types: number;
options: Record<string, unknown>;
}
interface NotificationAgentDiscord extends NotificationAgent {
export interface NotificationAgentDiscord extends NotificationAgentConfig {
options: {
webhookUrl: string;
};
}
interface NotificationAgentEmail extends NotificationAgent {
export interface NotificationAgentEmail extends NotificationAgentConfig {
options: {
emailFrom: string;
smtpHost: string;
@@ -71,6 +74,7 @@ interface NotificationAgentEmail extends NotificationAgent {
secure: boolean;
authUser?: string;
authPass?: string;
allowSelfSigned: boolean;
};
}
@@ -104,11 +108,13 @@ class Settings {
main: {
apiKey: '',
applicationUrl: '',
defaultPermissions: Permission.REQUEST,
},
plex: {
name: '',
ip: '127.0.0.1',
port: 32400,
useSsl: false,
libraries: [],
},
radarr: [],
@@ -124,8 +130,9 @@ class Settings {
options: {
emailFrom: '',
smtpHost: '127.0.0.1',
smtpPort: 465,
smtpPort: 587,
secure: false,
allowSelfSigned: false,
},
},
discord: {
@@ -211,7 +218,7 @@ class Settings {
}
private generateApiKey(): string {
return Buffer.from(`${Date.now()}${this.clientId}`).toString('base64');
return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64');
}
/**

View File

@@ -16,15 +16,23 @@ const hformat = winston.format.printf(
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'debug',
format: winston.format.combine(
winston.format.colorize(),
winston.format.splat(),
winston.format.timestamp(),
hformat
),
transports: [
new winston.transports.Console(),
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.splat(),
winston.format.timestamp(),
hformat
),
}),
new winston.transports.File({
filename: path.join(__dirname, '../config/logs/overseerr.log'),
maxsize: 20971520,
maxFiles: 6,
}),
],
});

View File

@@ -1,9 +1,25 @@
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
export const checkUser: Middleware = async (req, _res, next) => {
if (req.session?.userId) {
const settings = getSettings();
if (req.header('X-API-Key') === settings.main.apiKey) {
const userRepository = getRepository(User);
let userId = 1; // Work on original administrator account
// If a User ID is provided, we will act on that users behalf
if (req.header('X-API-User')) {
userId = Number(req.header('X-API-User'));
}
const user = await userRepository.findOne({ where: { id: userId } });
if (user) {
req.user = user;
}
} else if (req.session?.userId) {
const userRepository = getRepository(User);
const user = await userRepository.findOne({
@@ -24,7 +40,7 @@ export const isAuthenticated = (
if (!req.user || !req.user.hasPermission(permissions ?? 0)) {
res.status(403).json({
status: 403,
error: 'You do not have permisson to access this endpoint',
error: 'You do not have permission to access this endpoint',
});
} else {
next();

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm';
export class DropImdbIdConstraint1607928251245 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropUniqueConstraint(
'media',
'UQ_7ff2d11f6a83cb52386eaebe74b'
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createUniqueConstraint(
'media',
new TableUnique({
name: 'UQ_7ff2d11f6a83cb52386eaebe74b',
columnNames: ['imdbId'],
})
);
}
}

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserRequestDeleteCascades1608219049304
implements MigrationInterface {
name = 'AddUserRequestDeleteCascades1608219049304';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"`
);
await queryRunner.query(`DROP TABLE "media_request"`);
await queryRunner.query(
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
);
await queryRunner.query(
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"`
);
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLastSeasonChangeMedia1608477467935
implements MigrationInterface {
name = 'AddLastSeasonChangeMedia1608477467935';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
await queryRunner.query(
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt" FROM "temporary_media"`
);
await queryRunner.query(`DROP TABLE "temporary_media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ForceDropImdbUniqueConstraint1608477467935
implements MigrationInterface {
name = 'ForceDropImdbUniqueConstraint1608477467936';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
await queryRunner.query(
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt" FROM "temporary_media"`
);
await queryRunner.query(`DROP TABLE "temporary_media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
}
}

View File

@@ -78,7 +78,7 @@ export const mapTvResult = (
media?: Media
): TvResult => ({
id: tvResult.id,
firstAirDate: tvResult.first_air_Date,
firstAirDate: tvResult.first_air_date,
genreIds: tvResult.genre_ids,
// Some results from tmdb dont return the mediaType so we force it here!
mediaType: tvResult.media_type || 'tv',

View File

@@ -7,6 +7,7 @@ import {
mapCrew,
ExternalIds,
mapExternalIds,
Keyword,
} from './common';
import {
TmdbTvEpisodeResult,
@@ -45,6 +46,12 @@ export interface SeasonWithEpisodes extends Season {
externalIds: ExternalIds;
}
interface SpokenLanguage {
englishName: string;
iso_639_1: string;
name: string;
}
export interface TvDetails {
id: number;
backdropPath?: string;
@@ -56,7 +63,7 @@ export interface TvDetails {
profilePath?: string;
}[];
episodeRunTime: number[];
firstAirDate: string;
firstAirDate?: string;
genres: Genre[];
homepage: string;
inProduction: boolean;
@@ -74,6 +81,7 @@ export interface TvDetails {
overview: string;
popularity: number;
productionCompanies: ProductionCompany[];
spokenLanguages: SpokenLanguage[];
seasons: Season[];
status: string;
type: string;
@@ -84,6 +92,7 @@ export interface TvDetails {
crew: Crew[];
};
externalIds: ExternalIds;
keywords: Keyword[];
mediaInfo?: Media;
}
@@ -161,6 +170,11 @@ export const mapTvDetails = (
originCountry: company.origin_country,
logoPath: company.logo_path,
})),
spokenLanguages: show.spoken_languages.map((language) => ({
englishName: language.english_name,
iso_639_1: language.iso_639_1,
name: language.name,
})),
seasons: show.seasons.map(mapSeasonResult),
status: show.status,
type: show.type,
@@ -179,5 +193,9 @@ export const mapTvDetails = (
crew: show.credits.crew.map(mapCrew),
},
externalIds: mapExternalIds(show.external_ids),
keywords: show.keywords.results.map((keyword) => ({
id: keyword.id,
name: keyword.name,
})),
mediaInfo: media,
});

View File

@@ -11,6 +11,11 @@ export interface ProductionCompany {
name: string;
}
export interface Keyword {
id: number;
name: string;
}
export interface Genre {
id: number;
name: string;

View File

@@ -5,6 +5,7 @@ import PlexTvAPI from '../api/plextv';
import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
import logger from '../logger';
import { getSettings } from '../lib/settings';
const authRoutes = Router();
@@ -14,7 +15,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
return res.status(500).json({
status: 500,
error:
'Requsted user endpoint withuot valid authenticated user in session',
'Requested user endpoint without valid authenticated user in session',
});
}
const user = await userRepository.findOneOrFail({
@@ -24,7 +25,8 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
return res.status(200).json(user.filter());
});
authRoutes.post('/login', async (req, res) => {
authRoutes.post('/login', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as { authToken?: string };
@@ -69,37 +71,58 @@ authRoutes.post('/login', async (req, res) => {
await userRepository.save(user);
}
// If we get to this point, the user does not already exist so we need to create the
// user _assuming_ they have access to the plex server
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (await mainPlexTv.checkUserAccess(account)) {
user = new User({
email: account.email,
username: account.username,
plexId: account.id,
plexToken: account.authToken,
permissions: Permission.REQUEST,
avatar: account.thumb,
// Double check that we didn't create the first admin user before running this
if (!user) {
// If we get to this point, the user does not already exist so we need to create the
// user _assuming_ they have access to the plex server
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
await userRepository.save(user);
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (await mainPlexTv.checkUserAccess(account)) {
user = new User({
email: account.email,
username: account.username,
plexId: account.id,
plexToken: account.authToken,
permissions: settings.main.defaultPermissions,
avatar: account.thumb,
});
await userRepository.save(user);
} else {
logger.info(
'Failed login attempt from user without access to plex server',
{
label: 'Auth',
account: {
...account,
authentication_token: '__REDACTED__',
authToken: '__REDACTED__',
},
}
);
return next({
status: 403,
message: 'You do not have access to this Plex server',
});
}
}
}
// Set logged in session
if (req.session && user) {
if (req.session) {
req.session.userId = user.id;
}
return res.status(200).json(user?.filter() ?? {});
} catch (e) {
logger.error(e.message, { label: 'Auth' });
res
.status(500)
.json({ error: 'Something went wrong. Is your auth token valid?' });
return next({
status: 500,
message: 'Something went wrong. Is your auth token valid?',
});
}
});

View File

@@ -121,4 +121,36 @@ discoverRoutes.get('/trending', async (req, res) => {
});
});
discoverRoutes.get<{ keywordId: string }>(
'/keyword/:keywordId/movies',
async (req, res) => {
const tmdb = new TheMovieDb();
const data = await tmdb.getMoviesByKeyword({
keywordId: Number(req.params.keywordId),
page: Number(req.query.page),
language: req.query.language as string,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});
}
);
export default discoverRoutes;

View File

@@ -16,20 +16,36 @@ import logger from '../logger';
import { scheduledJobs } from '../job/schedule';
import { Permission } from '../lib/permissions';
import { isAuthenticated } from '../middleware/auth';
import { merge } from 'lodash';
import { merge, omit } from 'lodash';
import Media from '../entity/Media';
import { MediaRequest } from '../entity/MediaRequest';
import { getAppVersion } from '../utils/appVersion';
import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces';
import { Notification } from '../lib/notifications';
import DiscordAgent from '../lib/notifications/agents/discord';
import EmailAgent from '../lib/notifications/agents/email';
const settingsRoutes = Router();
settingsRoutes.get('/main', (req, res) => {
const settings = getSettings();
if (!req.user?.hasPermission(Permission.ADMIN)) {
return res.status(200).json({
applicationUrl: settings.main.applicationUrl,
} as Partial<MainSettings>);
const filteredMainSettings = (
user: User,
main: MainSettings
): Partial<MainSettings> => {
if (!user?.hasPermission(Permission.ADMIN)) {
return omit(main, 'apiKey');
}
res.status(200).json(settings.main);
return main;
};
settingsRoutes.get('/main', (req, res, next) => {
const settings = getSettings();
if (!req.user) {
return next({ status: 500, message: 'User missing from request' });
}
res.status(200).json(filteredMainSettings(req.user, settings.main));
});
settingsRoutes.post('/main', (req, res) => {
@@ -41,6 +57,18 @@ settingsRoutes.post('/main', (req, res) => {
return res.status(200).json(settings.main);
});
settingsRoutes.get('/main/regenerate', (req, res, next) => {
const settings = getSettings();
const main = settings.regenerateApiKey();
if (!req.user) {
return next({ status: 500, message: 'User missing from request' });
}
return res.status(200).json(filteredMainSettings(req.user, main));
});
settingsRoutes.get('/plex', (_req, res) => {
const settings = getSettings();
@@ -91,17 +119,22 @@ settingsRoutes.get('/plex/library', async (req, res) => {
const libraries = await plexapi.getLibraries();
const newLibraries: Library[] = libraries.map((library) => {
const existing = settings.plex.libraries.find(
(l) => l.id === library.key
);
const newLibraries: Library[] = libraries
// Remove libraries that are not movie or show
.filter((library) => library.type === 'movie' || library.type === 'show')
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
.filter((library) => library.agent !== 'com.plexapp.agents.none')
.map((library) => {
const existing = settings.plex.libraries.find(
(l) => l.id === library.key && l.name === library.title
);
return {
id: library.key,
name: library.title,
enabled: existing?.enabled ?? false,
};
});
return {
id: library.key,
name: library.title,
enabled: existing?.enabled ?? false,
};
});
settings.plex.libraries = newLibraries;
}
@@ -416,6 +449,25 @@ settingsRoutes.post('/notifications/discord', (req, res) => {
res.status(200).json(settings.notifications.agents.discord);
});
settingsRoutes.post('/notifications/discord/test', (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
});
}
const discordAgent = new DiscordAgent(req.body);
discordAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
});
settingsRoutes.get('/notifications/email', (_req, res) => {
const settings = getSettings();
@@ -431,4 +483,38 @@ settingsRoutes.post('/notifications/email', (req, res) => {
res.status(200).json(settings.notifications.agents.email);
});
settingsRoutes.post('/notifications/email/test', (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
});
}
const emailAgent = new EmailAgent(req.body);
emailAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
});
settingsRoutes.get('/about', async (req, res) => {
const mediaRepository = getRepository(Media);
const mediaRequestRepository = getRepository(MediaRequest);
const totalMediaItems = await mediaRepository.count();
const totalRequests = await mediaRequestRepository.count();
return res.status(200).json({
version: getAppVersion(),
totalMediaItems,
totalRequests,
tz: process.env.TZ,
} as SettingsAboutResponse);
});
export default settingsRoutes;

View File

@@ -4,6 +4,7 @@ import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv';
import { mapTvResult } from '../models/Search';
import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger';
const tvRoutes = Router();
@@ -19,6 +20,10 @@ tvRoutes.get('/:id', async (req, res, next) => {
return res.status(200).json(mapTvDetails(tv, media));
} catch (e) {
logger.error('Failed to get tv show', {
label: 'API',
errorMessage: e.message,
});
return next({ status: 404, message: 'TV Show does not exist' });
}
});
@@ -101,7 +106,7 @@ tvRoutes.get('/:id/ratings', async (req, res, next) => {
const rtratings = await rtapi.getTVRatings(
tv.name,
Number(tv.first_air_date.slice(0, 4))
tv.first_air_date ? Number(tv.first_air_date.slice(0, 4)) : undefined
);
if (!rtratings) {

View File

@@ -1,7 +1,11 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import PlexTvAPI from '../api/plextv';
import { MediaRequest } from '../entity/MediaRequest';
import { User } from '../entity/User';
import { hasPermission, Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
const router = Router();
@@ -94,13 +98,96 @@ router.delete<{ id: string }>('/:id', async (req, res, next) => {
try {
const userRepository = getRepository(User);
const user = await userRepository.findOneOrFail({
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
relations: ['requests'],
});
if (!user) {
return next({ status: 404, message: 'User not found' });
}
if (user.id === 1) {
return next({ status: 405, message: 'This account cannot be deleted.' });
}
if (user.hasPermission(Permission.ADMIN)) {
return next({
status: 405,
message: 'You cannot delete users with administrative privileges.',
});
}
const requestRepository = getRepository(MediaRequest);
/**
* Requests are usually deleted through a cascade constraint. Those however, do
* not trigger the removal event so listeners to not run and the parent Media
* will not be updated back to unknown for titles that were still pending. So
* we manually remove all requests from the user here so the parent media's
* properly reflect the change.
*/
await requestRepository.remove(user.requests);
await userRepository.delete(user.id);
return res.status(200).json(user.filter());
} catch (e) {
next({ status: 404, message: 'User not found' });
logger.error('Something went wrong deleting a user', {
label: 'API',
userId: req.params.id,
errorMessage: e.message,
});
return next({
status: 500,
message: 'Something went wrong deleting the user',
});
}
});
router.post('/import-from-plex', async (req, res, next) => {
try {
const settings = getSettings();
const userRepository = getRepository(User);
// taken from auth.ts
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
const plexUsersResponse = await mainPlexTv.getUsers();
const createdUsers: User[] = [];
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
const user = await userRepository.findOne({
where: { plexId: account.id },
});
if (user) {
// Update the users avatar with their plex thumbnail (incase it changed)
user.avatar = account.thumb;
user.email = account.email;
user.username = account.username;
await userRepository.save(user);
} else {
// Check to make sure it's a real account
if (account.email && account.username) {
const newUser = new User({
username: account.username,
email: account.email,
permissions: settings.main.defaultPermissions,
plexId: parseInt(account.id),
plexToken: '',
avatar: account.thumb,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
}
}
return res.status(201).json(User.filterMany(createdUsers));
} catch (e) {
next({ status: 500, message: e.message });
}
});

View File

@@ -0,0 +1,114 @@
import {
EntitySubscriberInterface,
EventSubscriber,
getRepository,
UpdateEvent,
} from 'typeorm';
import TheMovieDb from '../api/themoviedb';
import { MediaStatus, MediaType } from '../constants/media';
import Media from '../entity/Media';
import { MediaRequest } from '../entity/MediaRequest';
import notificationManager, { Notification } from '../lib/notifications';
@EventSubscriber()
export class MediaSubscriber implements EntitySubscriberInterface {
private async notifyAvailableMovie(entity: Media) {
if (entity.status === MediaStatus.AVAILABLE) {
if (entity.mediaType === MediaType.MOVIE) {
const requestRepository = getRepository(MediaRequest);
const relatedRequests = await requestRepository.find({
where: { media: entity },
});
if (relatedRequests.length > 0) {
const tmdb = new TheMovieDb();
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
relatedRequests.forEach((request) => {
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
notifyUser: request.requestedBy,
subject: movie.title,
message: movie.overview,
media: entity,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
});
}
}
}
}
private async notifyAvailableSeries(entity: Media, dbEntity: Media) {
const newAvailableSeasons = entity.seasons
.filter((season) => season.status === MediaStatus.AVAILABLE)
.map((season) => season.seasonNumber);
const oldAvailableSeasons = dbEntity.seasons
.filter((season) => season.status === MediaStatus.AVAILABLE)
.map((season) => season.seasonNumber);
const changedSeasons = newAvailableSeasons.filter(
(seasonNumber) => !oldAvailableSeasons.includes(seasonNumber)
);
if (changedSeasons.length > 0) {
const tmdb = new TheMovieDb();
const requestRepository = getRepository(MediaRequest);
const processedSeasons: number[] = [];
for (const changedSeasonNumber of changedSeasons) {
const requests = await requestRepository.find({
where: { media: entity },
});
const request = requests.find(
(request) =>
// Check if the season is complete AND it contains the current season that was just marked available
request.seasons.every((season) =>
newAvailableSeasons.includes(season.seasonNumber)
) &&
request.seasons.some(
(season) => season.seasonNumber === changedSeasonNumber
)
);
if (request && !processedSeasons.includes(changedSeasonNumber)) {
processedSeasons.push(
...request.seasons.map((season) => season.seasonNumber)
);
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
subject: tv.name,
message: tv.overview,
notifyUser: request.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
media: entity,
extra: [
{
name: 'Seasons',
value: request.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
}
}
}
}
public beforeUpdate(event: UpdateEvent<Media>): void {
if (
event.entity.mediaType === MediaType.MOVIE &&
event.entity.status === MediaStatus.AVAILABLE
) {
this.notifyAvailableMovie(event.entity);
}
if (
event.entity.mediaType === MediaType.TV &&
(event.entity.status === MediaStatus.AVAILABLE ||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
) {
this.notifyAvailableSeries(event.entity, event.databaseEntity);
}
}
}

View File

@@ -47,7 +47,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
padding-bottom: 25px;\
text-align: center;\
')
a(href='https://example.com' style='\
a(href=applicationUrl style='\
text-shadow: 0 1px 0 #ffffff;\
font-weight: 700;\
font-size: 16px;\
@@ -92,7 +92,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
margin-bottom: 20px;\
color: #51545e;\
')
a(href=actionUrl style='color: #3869d4') Open Overseerr
a(href=actionUrl style='color: #3869d4') Open Media in Overseerr
tr
td
table.sm-w-full(align='center' style='\

View File

@@ -0,0 +1,96 @@
doctype html
head
meta(charset='utf-8')
meta(name='x-apple-disable-message-reformatting')
meta(http-equiv='x-ua-compatible' content='ie=edge')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&amp;display=swap' rel='stylesheet' media='screen')
//if mso
xml
o:officedocumentsettings
o:pixelsperinch 96
style.
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Segoe UI', sans-serif;
mso-line-height-rule: exactly;
}
style.
@media (max-width: 600px) {
.sm-w-full {
width: 100% !important;
}
}
div(role='article' aria-roledescription='email' aria-label='' lang='en')
table(style="\
background-color: #f2f4f6;\
font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
width: 100%;\
" width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center')
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='\
font-size: 16px;\
padding-top: 25px;\
padding-bottom: 25px;\
text-align: center;\
')
a(href=applicationUrl style='\
text-shadow: 0 1px 0 #ffffff;\
font-weight: 700;\
font-size: 16px;\
color: #a8aaaf;\
text-decoration: none;\
')
| Overseerr
tr
td(style='width: 100%' width='100%')
table.sm-w-full(align='center' style='\
background-color: #ffffff;\
margin-left: auto;\
margin-right: auto;\
width: 570px;\
' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
tr
td(style='padding: 45px')
div(style='font-size: 16px')
| #{body}
p(style='\
font-size: 13px;\
line-height: 24px;\
margin-top: 6px;\
margin-bottom: 20px;\
color: #51545e;\
')
a(href=applicationUrl style='color: #3869d4') Open Overseerr
tr
td
table.sm-w-full(align='center' style='\
margin-left: auto;\
margin-right: auto;\
text-align: center;\
width: 570px;\
' width='570' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='font-size: 16px; padding: 45px')
p(style='\
font-size: 13px;\
line-height: 24px;\
margin-top: 6px;\
margin-bottom: 20px;\
text-align: center;\
color: #a8aaaf;\
')
| Overseerr.

View File

@@ -0,0 +1 @@
= `Test Notification - Overseerr`

View File

@@ -2,8 +2,9 @@ declare module 'plex-api' {
export default class PlexAPI {
constructor(intiialOptions: {
hostname: string;
post: number;
port: number;
token?: string;
https?: boolean;
authenticator: {
authenticate: (
_plexApi: PlexAPI,

View File

@@ -0,0 +1,12 @@
export const getAppVersion = (): string => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { version } = require('../../package.json');
let finalVersion = version;
if (version === '0.1.0') {
finalVersion = `develop-${process.env.COMMIT_TAG ?? 'local'}`;
}
return finalVersion;
};

6
src/assets/ellipsis.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="22" height="10" viewBox="0 0 22 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="22" height="10" rx="2" fill="#718096"/>
<circle cx="5" cy="5" r="2" fill="#E2E8F0"/>
<circle cx="11" cy="5" r="2" fill="#E2E8F0"/>
<circle cx="17" cy="5" r="2" fill="#E2E8F0"/>
</svg>

After

Width:  |  Height:  |  Size: 293 B

View File

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,36 @@
import React from 'react';
interface AlertProps {
title: string;
type?: 'warning';
}
const Alert: React.FC<AlertProps> = ({ title, children }) => {
return (
<div className="rounded-md bg-yellow-600 p-4 mb-8">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-yellow-200"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-200">{title}</h3>
<div className="mt-2 text-sm text-yellow-300">{children}</div>
</div>
</div>
</div>
);
};
export default Alert;

View File

@@ -8,12 +8,18 @@ import React, {
interface ImageFaderProps extends HTMLAttributes<HTMLDivElement> {
backgroundImages: string[];
rotationSpeed?: number;
isDarker?: boolean;
}
const DEFAULT_ROTATION_SPEED = 6000;
const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
{ backgroundImages, rotationSpeed = DEFAULT_ROTATION_SPEED, ...props },
{
backgroundImages,
rotationSpeed = DEFAULT_ROTATION_SPEED,
isDarker,
...props
},
ref
) => {
const [activeIndex, setIndex] = useState(0);
@@ -29,6 +35,14 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
};
}, [backgroundImages, rotationSpeed]);
let gradient =
'linear-gradient(180deg, rgba(45, 55, 72, 0.47) 0%, #1A202E 100%)';
if (isDarker) {
gradient =
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)';
}
return (
<div ref={ref}>
{backgroundImages.map((imageUrl, i) => (
@@ -38,7 +52,7 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
i === activeIndex ? 'opacity-100' : 'opacity-0'
}`}
style={{
backgroundImage: `linear-gradient(180deg, rgba(45, 55, 72, 0.47) 0%, #1A202E 100%), url(${imageUrl})`,
backgroundImage: `${gradient}, url(${imageUrl})`,
}}
{...props}
/>

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { withProperties } from '../../../utils/typeHelpers';
interface ListItemProps {
title: string;
}
const ListItem: React.FC<ListItemProps> = ({ title, children }) => {
return (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="text-sm font-medium text-gray-200">{title}</dt>
<dd className="mt-1 flex text-sm text-gray-400 sm:mt-0 sm:col-span-2">
<span className="flex-grow">{children}</span>
</dd>
</div>
);
};
interface ListProps {
title: string;
subTitle?: string;
}
const List: React.FC<ListProps> = ({ title, subTitle, children }) => {
return (
<>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-100">{title}</h3>
{subTitle && (
<p className="mt-1 max-w-2xl text-sm text-gray-300">{subTitle}</p>
)}
</div>
<div className="mt-5 border-t border-gray-800">
<dl className="divide-y divide-gray-800">{children}</dl>
</div>
</>
);
};
export default withProperties(List, { Item: ListItem });

View File

@@ -8,7 +8,7 @@ import { useIntl } from 'react-intl';
import globalMessages from '../../../i18n/globalMessages';
import Transition from '../../Transition';
interface ModalProps extends React.HTMLAttributes<HTMLDivElement> {
interface ModalProps {
title?: string;
onCancel?: (e?: MouseEvent<HTMLElement>) => void;
onOk?: (e?: MouseEvent<HTMLButtonElement>) => void;
@@ -63,126 +63,122 @@ const Modal: React.FC<ModalProps> = ({
});
useLockBodyScroll(true, disableScrollLock);
return (
<>
{ReactDOM.createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
return ReactDOM.createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="fixed top-0 left-0 right-0 bottom-0 bg-gray-800 bg-opacity-50 w-full h-full z-50 flex justify-center items-center"
onKeyDown={(e) => {
if (e.key === 'Escape') {
typeof onCancel === 'function' && backgroundClickable
? onCancel()
: undefined;
}
}}
>
<Transition
enter="transition opacity-0 duration-300 transform scale-75"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={loading}
>
<div style={{ position: 'absolute' }}>
<LoadingSpinner />
</div>
</Transition>
<Transition
enter="transition opacity-0 duration-300 transform scale-75"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={!loading}
>
<div
className="fixed top-0 left-0 right-0 bottom-0 bg-gray-800 bg-opacity-50 w-full h-full z-50 flex justify-center items-center"
onKeyDown={(e) => {
if (e.key === 'Escape') {
typeof onCancel === 'function' && backgroundClickable
? onCancel()
: undefined;
}
}}
className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full sm:p-6 max-h-full"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
ref={modalRef}
>
<Transition
enter="transition opacity-0 duration-300 transform scale-75"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={loading}
>
<div style={{ position: 'absolute' }}>
<LoadingSpinner />
</div>
</Transition>
<Transition
enter="transition opacity-0 duration-300 transform scale-75"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={!loading}
>
<div
className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full sm:p-6 max-h-full"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
ref={modalRef}
>
<div className="sm:flex sm:items-center">
{iconSvg && (
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-gray-600 text-white sm:mx-0 sm:h-10 sm:w-10">
{iconSvg}
</div>
)}
<div
className={`mt-3 text-center sm:mt-0 sm:text-left ${
iconSvg ? 'sm:ml-4' : 'mb-6'
}`}
>
{title && (
<h3
className="text-lg leading-6 font-medium text-white"
id="modal-headline"
>
{title}
</h3>
)}
</div>
<div className="sm:flex sm:items-center">
{iconSvg && (
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-gray-600 text-white sm:mx-0 sm:h-10 sm:w-10">
{iconSvg}
</div>
{children && (
<div className="mt-4 text-sm leading-5 text-gray-300">
{children}
</div>
)}
{(onCancel || onOk || onSecondary || onTertiary) && (
<div className="mt-5 sm:mt-4 flex justify-center sm:justify-start flex-row-reverse">
{typeof onOk === 'function' && (
<Button
buttonType={okButtonType}
onClick={onOk}
className="ml-3"
disabled={okDisabled}
>
{okText ? okText : 'Ok'}
</Button>
)}
{typeof onSecondary === 'function' && secondaryText && (
<Button
buttonType={secondaryButtonType}
onClick={onSecondary}
className="ml-3"
disabled={secondaryDisabled}
>
{secondaryText}
</Button>
)}
{typeof onTertiary === 'function' && tertiaryText && (
<Button
buttonType={tertiaryButtonType}
onClick={onTertiary}
className="ml-3"
disabled={tertiaryDisabled}
>
{tertiaryText}
</Button>
)}
{typeof onCancel === 'function' && (
<Button
buttonType={cancelButtonType}
onClick={onCancel}
className="ml-3 sm:ml-0 sm:px-4"
>
{cancelText
? cancelText
: intl.formatMessage(globalMessages.cancel)}
</Button>
)}
</div>
)}
<div
className={`mt-3 text-center sm:mt-0 sm:text-left ${
iconSvg ? 'sm:ml-4' : 'mb-6'
}`}
>
{title && (
<h3
className="text-lg leading-6 font-medium text-white"
id="modal-headline"
>
{title}
</h3>
)}
</div>
</Transition>
</div>,
document.body
)}
</>
</div>
{children && (
<div className="mt-4 text-sm leading-5 text-gray-300">
{children}
</div>
)}
{(onCancel || onOk || onSecondary || onTertiary) && (
<div className="mt-5 sm:mt-4 flex justify-center sm:justify-start flex-row-reverse">
{typeof onOk === 'function' && (
<Button
buttonType={okButtonType}
onClick={onOk}
className="ml-3"
disabled={okDisabled}
>
{okText ? okText : 'Ok'}
</Button>
)}
{typeof onSecondary === 'function' && secondaryText && (
<Button
buttonType={secondaryButtonType}
onClick={onSecondary}
className="ml-3"
disabled={secondaryDisabled}
>
{secondaryText}
</Button>
)}
{typeof onTertiary === 'function' && tertiaryText && (
<Button
buttonType={tertiaryButtonType}
onClick={onTertiary}
className="ml-3"
disabled={tertiaryDisabled}
>
{tertiaryText}
</Button>
)}
{typeof onCancel === 'function' && (
<Button
buttonType={cancelButtonType}
onClick={onCancel}
className="ml-3 sm:ml-0 sm:px-4"
>
{cancelText
? cancelText
: intl.formatMessage(globalMessages.cancel)}
</Button>
)}
</div>
)}
</div>
</Transition>
</div>,
document.body
);
};

View File

@@ -1,4 +1,3 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import Transition from '../../Transition';
@@ -49,7 +48,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
className={`z-50 fixed inset-0 overflow-hidden bg-opacity-50 bg-gray-800`}
>
<div className="absolute inset-0 overflow-hidden">
<section className="absolute inset-y-0 right-0 pl-10 max-w-full flex">
<section className="absolute inset-y-0 right-0 flex max-w-full pl-10">
<Transition
show={show}
appear
@@ -61,20 +60,20 @@ const SlideOver: React.FC<SlideOverProps> = ({
leaveTo="translate-x-full"
>
<div className="w-screen max-w-md" ref={slideoverRef}>
<div className="h-full flex flex-col bg-gray-700 shadow-xl overflow-y-scroll">
<header className="space-y-1 py-6 px-4 bg-indigo-600 sm:px-6">
<div className="flex flex-col h-full overflow-y-scroll bg-gray-700 shadow-xl">
<header className="px-4 py-6 space-y-1 bg-indigo-600 sm:px-6">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-lg leading-7 font-medium text-white">
<h2 className="text-lg font-medium leading-7 text-white">
{title}
</h2>
<div className="h-7 flex items-center">
<div className="flex items-center h-7">
<button
aria-label="Close panel"
className="text-indigo-200 hover:text-white transition ease-in-out duration-150"
className="text-indigo-200 transition duration-150 ease-in-out hover:text-white"
onClick={() => onClose()}
>
<svg
className="h-6 w-6"
className="w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -98,7 +97,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
</div>
)}
</header>
<div className="relative flex-1 py-6 px-4 sm:px-6 text-white">
<div className="relative flex-1 px-4 py-6 text-white sm:px-6">
{children}
</div>
</div>

View File

@@ -0,0 +1,70 @@
import React, { useContext } from 'react';
import { useSWRInfinite } from 'swr';
import type { MovieResult } from '../../../../server/models/Search';
import ListView from '../../Common/ListView';
import { LanguageContext } from '../../../context/LanguageContext';
import Header from '../../Common/Header';
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const Holiday: React.FC = () => {
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/discover/keyword/207317/movies?page=${
pageIndex + 1
}&language=${locale}`;
},
{
initialSize: 3,
}
);
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
if (error) {
return <div>{error}</div>;
}
const titles = data?.reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);
return (
<>
<Header>Happy Holidays!</Header>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default Holiday;

View File

@@ -63,6 +63,12 @@ const Discover: React.FC = () => {
} = useSWR<MovieDiscoverResult>(
`/api/v1/discover/movies/upcoming?language=${locale}`
);
const {
data: holUpcomingData,
error: holUpcomingError,
} = useSWR<MovieDiscoverResult>(
`/api/v1/discover/keyword/207317/movies?language=${locale}`
);
const { data: trendingData, error: trendingError } = useSWR<MixedResult>(
`/api/v1/discover/trending?language=${locale}`
@@ -140,6 +146,57 @@ const Discover: React.FC = () => {
placeholder={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.nopending)}
/>
{/* Special Temporary Slider */}
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="flex-1 min-w-0">
<Link href="/discover/holiday">
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<span>
<span role="img" aria-label="Christmas Tree" className="mr-2">
🎄
</span>
Happy Holidays!
<span role="img" aria-label="Christmas Tree" className="ml-2">
🎄
</span>
</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
</div>
<Slider
sliderKey="holiday"
isLoading={!holUpcomingData && !holUpcomingError}
isEmpty={false}
items={holUpcomingData?.results.map((title) => (
<TitleCard
key={`holiday-movie-slider-${title.id}`}
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
/>
))}
/>
{/* End Special Temporary Slider */}
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="flex-1 min-w-0">
<Link href="/discover/movies/upcoming">

View File

@@ -0,0 +1,55 @@
import React from 'react';
import TmdbLogo from '../../assets/services/tmdb.svg';
import ImdbLogo from '../../assets/services/imdb.svg';
import RTLogo from '../../assets/services/rt.svg';
interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv';
imdbId?: string;
tmdbId?: number;
rtUrl?: string;
}
const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
imdbId,
tmdbId,
rtUrl,
mediaType,
}) => {
return (
<div className="flex justify-end items-center">
{tmdbId && (
<a
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}
className="w-8 mx-2 opacity-50 hover:opacity-100 transition duration-300"
target="_blank"
rel="noreferrer"
>
<TmdbLogo />
</a>
)}
{imdbId && (
<a
href={`https://www.imdb.com/title/${imdbId}`}
className="w-8 mx-2 opacity-50 hover:opacity-100 transition duration-300"
target="_blank"
rel="noreferrer"
>
<ImdbLogo />
</a>
)}
{rtUrl && (
<a
href={`${rtUrl}`}
className="w-14 mx-2 opacity-50 hover:opacity-100 transition duration-300"
target="_blank"
rel="noreferrer"
>
<RTLogo />
</a>
)}
</div>
);
};
export default ExternalLinkBlock;

View File

@@ -45,6 +45,26 @@ const availableLanguages: AvailableLanguageObject = {
code: 'nl',
display: 'Nederlands',
},
es: {
code: 'es',
display: 'Spanish',
},
it: {
code: 'it',
display: 'Italian',
},
'pt-BR': {
code: 'pt-BR',
display: 'Portuguese (Brazil)',
},
sr: {
code: 'sr',
display: 'Serbian',
},
sv: {
code: 'sv',
display: 'Swedish',
},
};
const LanguagePicker: React.FC = () => {
@@ -54,7 +74,7 @@ const LanguagePicker: React.FC = () => {
useClickOutside(dropdownRef, () => setDropdownOpen(false));
return (
<div className="ml-3 relative">
<div className="relative">
<div>
<button
className="p-1 text-gray-400 rounded-full hover:bg-gray-500 hover:text-white focus:outline-none focus:ring focus:text-white"

View File

@@ -27,7 +27,7 @@ const SearchInput: React.FC = () => {
</div>
<input
id="search_field"
className="block w-full h-full pl-8 pr-3 py-2 rounded-md border-transparent focus:border-transparent bg-gray-600 text-white placeholder-gray-300 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
className="block w-full h-full pl-8 pr-1 py-2 rounded-md border-transparent focus:border-transparent bg-gray-600 text-white placeholder-gray-300 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
placeholder={intl.formatMessage(messages.searchPlaceholder)}
type="search"
value={searchValue}

View File

@@ -1,9 +1,10 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useRef } from 'react';
import Transition from '../../Transition';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { defineMessages, FormattedMessage } from 'react-intl';
import { useUser, Permission } from '../../../hooks/useUser';
import useClickOutside from '../../../hooks/useClickOutside';
const messages = defineMessages({
dashboard: 'Discover',
@@ -116,8 +117,10 @@ const SidebarLinks: SidebarLinkProps[] = [
];
const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
const navRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { hasPermission } = useUser();
useClickOutside(navRef, () => setClosed());
return (
<>
<div className="md:hidden">
@@ -166,7 +169,10 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
</svg>
</button>
</div>
<div className="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
<div
ref={navRef}
className="flex-1 h-0 pt-5 pb-4 overflow-y-auto"
>
<div className="flex-shrink-0 flex items-center px-4">
<span className="text-xl text-gray-50">
<Link href="/">

View File

@@ -2,10 +2,10 @@ import React, { useState } from 'react';
import SearchInput from './SearchInput';
import UserDropdown from './UserDropdown';
import Sidebar from './Sidebar';
import Notifications from './Notifications';
import LanguagePicker from './LanguagePicker';
import { useRouter } from 'next/router';
import { defineMessages, FormattedMessage } from 'react-intl';
import { Permission, useUser } from '../../hooks/useUser';
const messages = defineMessages({
alphawarning:
@@ -14,6 +14,7 @@ const messages = defineMessages({
const Layout: React.FC = ({ children }) => {
const [isSidebarOpen, setSidebarOpen] = useState(false);
const { hasPermission } = useUser();
const router = useRouter();
return (
@@ -43,9 +44,8 @@ const Layout: React.FC = ({ children }) => {
</button>
<div className="flex-1 px-4 flex justify-between">
<SearchInput />
<div className="ml-4 flex items-center md:ml-6">
<div className="flex items-center md:ml-6">
<LanguagePicker />
<Notifications />
<UserDropdown />
</div>
</div>
@@ -57,7 +57,7 @@ const Layout: React.FC = ({ children }) => {
>
<div className="pt-2 pb-6 md:py-6">
<div className="max-w-8xl mx-auto px-4 sm:px-6 md:px-8">
{router.pathname === '/' && (
{router.pathname === '/' && hasPermission(Permission.ADMIN) && (
<div className="rounded-md bg-indigo-700 p-4 mt-2">
<div className="flex">
<div className="flex-shrink-0">
@@ -85,7 +85,7 @@ const Layout: React.FC = ({ children }) => {
target="_blank"
rel="noreferrer"
>
Github &rarr;
GitHub &rarr;
</a>
</p>
</div>

View File

@@ -5,25 +5,36 @@ import axios from 'axios';
import { useRouter } from 'next/dist/client/router';
import ImageFader from '../Common/ImageFader';
import { defineMessages, FormattedMessage } from 'react-intl';
import Transition from '../Transition';
import LanguagePicker from '../Layout/LanguagePicker';
const messages = defineMessages({
signinplex: 'Sign in to continue',
});
const Login: React.FC = () => {
const [error, setError] = useState('');
const [isProcessing, setProcessing] = useState(false);
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const { user, revalidate } = useUser();
const router = useRouter();
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to login. If we get a success message, we will
// ask swr to revalidate the user which _shouid_ come back with a valid user.
// ask swr to revalidate the user which _should_ come back with a valid user.
useEffect(() => {
const login = async () => {
const response = await axios.post('/api/v1/auth/login', { authToken });
setProcessing(true);
try {
const response = await axios.post('/api/v1/auth/login', { authToken });
if (response.data?.email) {
revalidate();
if (response.data?.email) {
revalidate();
}
} catch (e) {
setError(e.response.data.message);
setAuthToken(undefined);
setProcessing(false);
}
};
if (authToken) {
@@ -49,7 +60,10 @@ const Login: React.FC = () => {
'/images/rotate4.jpg',
]}
/>
<div className="px-4 sm:px-2 md:px-0 sm:mx-auto sm:w-full sm:max-w-md relative z-50">
<div className="absolute top-4 right-4 z-50">
<LanguagePicker />
</div>
<div className="px-4 sm:px-2 md:px-0 sm:mx-auto sm:w-full sm:max-w-md relative z-40">
<img
src="/logo.png"
className="mx-auto max-h-32 w-auto"
@@ -64,7 +78,40 @@ const Login: React.FC = () => {
className="bg-gray-800 bg-opacity-50 py-8 px-4 shadow sm:rounded-lg sm:px-10"
style={{ backdropFilter: 'blur(5px)' }}
>
<Transition
show={!!error}
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="rounded-md bg-red-600 p-4 mb-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-300">{error}</h3>
</div>
</div>
</div>
</Transition>
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
/>
</div>

View File

@@ -42,11 +42,11 @@ const MovieCast: React.FC = () => {
{intl.formatMessage(messages.fullcast)}
</Header>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{data?.credits.cast.map((person) => {
{data?.credits.cast.map((person, index) => {
return (
<li
key={person.id}
className="col-span-1 flex flex-col text-center items-center"
key={`cast-${person.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<PersonCard
name={person.name}

View File

@@ -0,0 +1,66 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useContext } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MovieDetails } from '../../../../server/models/Movie';
import { LanguageContext } from '../../../context/LanguageContext';
import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
const messages = defineMessages({
fullcrew: 'Full Crew',
});
const MovieCrew: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { data, error } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
return (
<>
<Header
subtext={
<Link href={`/movie/${data.id}`}>
<a className="hover:underline">{data.title}</a>
</Link>
}
>
{intl.formatMessage(messages.fullcrew)}
</Header>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{data?.credits.crew.map((person, index) => {
return (
<li
key={`crew-${person.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<PersonCard
name={person.name}
personId={person.id}
subName={person.job}
profilePath={person.profilePath}
canExpand
/>
</li>
);
})}
</ul>
</>
);
};
export default MovieCrew;

View File

@@ -1,4 +1,4 @@
import React, { useState, useContext } from 'react';
import React, { useState, useContext, useMemo } from 'react';
import {
FormattedMessage,
defineMessages,
@@ -37,6 +37,8 @@ import type { RTRating } from '../../../server/api/rottentomatoes';
import Error from '../../pages/_error';
import Head from 'next/head';
import globalMessages from '../../i18n/globalMessages';
import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
const messages = defineMessages({
releasedate: 'Release Date',
@@ -65,6 +67,8 @@ const messages = defineMessages({
'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.',
approve: 'Approve',
decline: 'Decline',
studio: 'Studio',
viewfullcrew: 'View Full Crew',
});
interface MovieDetailsProps {
@@ -101,6 +105,10 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
`/api/v1/movie/${router.query.movieId}/ratings`
);
const sortedCrew = useMemo(() => sortCrewPriority(data?.credits.crew ?? []), [
data,
]);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -132,7 +140,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
return (
<div
className="bg-cover bg-center -mx-4 -mt-2 px-4 sm:px-8 pt-4 "
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 "
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
@@ -157,21 +165,21 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
onClose={() => setShowManager(false)}
subText={data.title}
>
<h3 className="text-xl mb-2">
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="bg-gray-600 shadow overflow-hidden rounded-md">
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.requests?.map((request) => (
<li
key={`manage-request-${request.id}`}
className="border-b last:border-b-0 border-gray-700"
className="border-b border-gray-700 last:border-b-0"
>
<RequestBlock request={request} onUpdate={() => revalidate()} />
</li>
))}
{(data.mediaInfo?.requests ?? []).length === 0 && (
<li className="text-center py-4 text-gray-400">
<li className="py-4 text-center text-gray-400">
{intl.formatMessage(messages.manageModalNoRequests)}
</li>
)}
@@ -186,21 +194,21 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
>
{intl.formatMessage(messages.manageModalClearMedia)}
</Button>
<div className="text-sm text-gray-400 mt-2">
<div className="mt-2 text-sm text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning)}
</div>
</div>
)}
</SlideOver>
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
<div className="mr-4 flex-shrink-0">
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
<div className="flex-shrink-0 md:mr-4">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-52"
/>
</div>
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
<div className="flex flex-col mt-4 text-center text-white md:mr-4 md:mt-0 md:text-left">
<div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
@@ -222,7 +230,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{data.title}{' '}
<span className="text-2xl">({data.releaseDate.slice(0, 4)})</span>
</h1>
<span className="text-xs md:text-base mt-1 md:mt-0">
<span className="mt-1 text-xs md:text-base md:mt-0">
{(data.runtime ?? 0) > 0 && (
<>
<FormattedMessage
@@ -235,7 +243,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{data.genres.map((g) => g.name).join(', ')}
</span>
</div>
<div className="flex-1 flex justify-end mt-4 md:mt-0">
<div className="flex justify-end flex-1 mt-4 md:mt-0">
{(!data.mediaInfo ||
data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
<Button
@@ -352,7 +360,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"
className="ml-2"
className="ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<svg
@@ -380,7 +388,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)}
</div>
</div>
<div className="flex pt-8 text-white flex-col md:flex-row pb-4">
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">
<FormattedMessage {...messages.overview} />
@@ -390,45 +398,85 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
<ul className="grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3">
{sortedCrew.slice(0, 6).map((person) => (
<li
className="flex flex-col col-span-1"
key={`crew-${person.job}-${person.id}`}
>
<span className="font-bold">{person.job}</span>
<Link href={`/person/${person.id}`}>
<a className="text-gray-400 transition duration-300 hover:text-underline hover:text-gray-100">
{person.name}
</a>
</Link>
</li>
))}
</ul>
{sortedCrew.length > 0 && (
<div className="flex justify-end mt-4">
<Link href={`/movie/${data.id}/crew`}>
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
<svg
className="inline-block w-5 h-5 ml-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
)}
</div>
<div className="w-full md:w-80 mt-8 md:mt-0">
<div className="bg-gray-900 rounded-lg shadow border border-gray-800">
<div className="w-full mt-8 md:w-80 md:mt-0">
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
{(data.voteCount > 0 || ratingData) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0 items-center justify-center">
{ratingData?.criticsRating && (
<>
<span className="text-sm">
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="w-6 mr-1" />
) : (
<RTFresh className="w-6 mr-1" />
)}
</span>
<span className="text-gray-400 text-sm mr-4 last:mr-0">
{ratingData.criticsScore}%
</span>
</>
)}
{ratingData?.audienceRating && (
<>
<span className="text-sm">
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6 mr-1" />
) : (
<RTAudFresh className="w-6 mr-1" />
)}
</span>
<span className="text-gray-400 text-sm mr-4 last:mr-0">
{ratingData.audienceScore}%
</span>
</>
)}
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
{ratingData?.criticsRating &&
(ratingData?.criticsScore ?? 0) > 0 && (
<>
<span className="text-sm">
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="w-6 mr-1" />
) : (
<RTFresh className="w-6 mr-1" />
)}
</span>
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.criticsScore}%
</span>
</>
)}
{ratingData?.audienceRating &&
(ratingData?.audienceScore ?? 0) > 0 && (
<>
<span className="text-sm">
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6 mr-1" />
) : (
<RTAudFresh className="w-6 mr-1" />
)}
</span>
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.audienceScore}%
</span>
</>
)}
{data.voteCount > 0 && (
<>
<span className="text-sm">
<TmdbLogo className="w-6 mr-2" />
</span>
<span className="text-gray-400 text-sm">
<span className="text-sm text-gray-400">
{data.voteAverage}/10
</span>
</>
@@ -439,7 +487,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<FormattedMessage {...messages.releasedate} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedDate
value={new Date(data.releaseDate)}
year="numeric"
@@ -452,7 +500,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<FormattedMessage {...messages.status} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
{data.status}
</span>
</div>
@@ -461,7 +509,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<FormattedMessage {...messages.revenue} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedNumber
currency="USD"
style="currency"
@@ -475,7 +523,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<FormattedMessage {...messages.budget} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedNumber
currency="USD"
style="currency"
@@ -484,21 +532,47 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</span>
</div>
)}
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.originallanguage} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
{data.originalLanguage}
</span>
</div>
{data.spokenLanguages.some(
(lng) => lng.iso_639_1 === data.originalLanguage
) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.originallanguage} />
</span>
<span className="flex-1 text-sm text-right text-gray-400">
{
data.spokenLanguages.find(
(lng) => lng.iso_639_1 === data.originalLanguage
)?.name
}
</span>
</div>
)}
{data.productionCompanies[0] && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.studio} />
</span>
<span className="flex-1 text-sm text-right text-gray-400">
{data.productionCompanies[0]?.name}
</span>
</div>
)}
</div>
<div className="mt-4">
<ExternalLinkBlock
mediaType="movie"
tmdbId={data.id}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
/>
</div>
</div>
</div>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.cast} />
</span>
@@ -536,13 +610,13 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
/>
{(recommended?.results ?? []).length > 0 && (
<>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link
href="/movie/[movieId]/recommendations"
as={`/movie/${data.id}/recommendations`}
>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.recommendations} />
</span>
@@ -586,13 +660,13 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)}
{(similar?.results ?? []).length > 0 && (
<>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link
href="/movie/[movieId]/similar"
as={`/movie/${data.id}/similar`}
>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.similar} />
</span>

View File

@@ -25,18 +25,19 @@ const PersonCard: React.FC<PersonCardProps> = ({
} bg-gray-600 rounded-lg text-white shadow-lg hover:bg-gray-500 transition ease-in-out duration-150 cursor-pointer`}
>
<div style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2">
{profilePath && (
<div
style={{
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath})`,
}}
className="rounded-full w-28 h-28 md:w-32 md:h-32 bg-cover bg-center mb-6"
/>
<div className="relative flex justify-center w-full mt-2 mb-4 h-1/2">
<img
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full"
alt=""
/>
</div>
)}
{!profilePath && (
<svg
className="w-28 h-28 md:w-32 md:h-32 mb-6"
className="mb-6 w-28 h-28 md:w-32 md:h-32"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
@@ -48,12 +49,21 @@ const PersonCard: React.FC<PersonCardProps> = ({
/>
</svg>
)}
<div className="whitespace-normal text-center">{name}</div>
<div className="w-full text-center truncate">{name}</div>
{subName && (
<div className="whitespace-normal text-center text-sm text-gray-300">
<div
className="overflow-hidden text-sm text-center text-gray-300 whitespace-normal"
style={{
WebkitLineClamp: 2,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
}}
>
{subName}
</div>
)}
<div className="absolute bottom-0 left-0 right-0 h-12 rounded-b-lg bg-gradient-to-t from-gray-600" />
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { useRouter } from 'next/router';
import React, { useContext } from 'react';
import React, { useContext, useState } from 'react';
import TruncateMarkup from 'react-truncate-markup';
import useSWR from 'swr';
import type { PersonDetail } from '../../../server/models/Person';
import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces';
@@ -8,9 +9,12 @@ import LoadingSpinner from '../Common/LoadingSpinner';
import TitleCard from '../TitleCard';
import { defineMessages, useIntl } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
import ImageFader from '../Common/ImageFader';
import Ellipsis from '../../assets/ellipsis.svg';
const messages = defineMessages({
appearsin: 'Appears in',
crewmember: 'Crew Member',
ascharacter: 'as {character}',
nobiography: 'No biography available.',
});
@@ -22,6 +26,7 @@ const PersonDetails: React.FC = () => {
const { data, error } = useSWR<PersonDetail>(
`/api/v1/person/${router.query.personId}`
);
const [showBio, setShowBio] = useState(false);
const {
data: combinedCredits,
@@ -53,77 +58,165 @@ const PersonDetails: React.FC = () => {
return 1;
});
const sortedCrew = combinedCredits?.crew.sort((a, b) => {
const aDate =
a.mediaType === 'movie'
? a.releaseDate?.slice(0, 4) ?? 0
: a.firstAirDate?.slice(0, 4) ?? 0;
const bDate =
b.mediaType === 'movie'
? b.releaseDate?.slice(0, 4) ?? 0
: b.firstAirDate?.slice(0, 4) ?? 0;
if (aDate > bDate) {
return -1;
}
return 1;
});
const isLoading = !combinedCredits && !errorCombinedCredits;
return (
<>
<div className="flex mt-8 mb-8 flex-col md:flex-row items-center md:items-start">
{(sortedCrew || sortedCast) && (
<div className="absolute top-0 left-0 right-0 z-0 h-96">
<ImageFader
isDarker
backgroundImages={[...(sortedCast ?? []), ...(sortedCrew ?? [])]
.filter((media) => media.backdropPath)
.map(
(media) =>
`//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`
)
.slice(0, 6)}
/>
</div>
)}
<div className="relative z-10 flex flex-col items-center mt-8 mb-8 md:flex-row md:items-start">
{data.profilePath && (
<div
style={{
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath})`,
}}
className="rounded-full w-36 h-36 md:w-44 md:h-44 bg-cover bg-center mb-6 md:mb-0 mr-0 md:mr-6 flex-shrink-0"
className="flex-shrink-0 mb-6 mr-0 bg-center bg-cover rounded-full w-36 h-36 md:w-44 md:h-44 md:mb-0 md:mr-6"
/>
)}
<div className="text-gray-300 text-center md:text-left">
<h1 className="text-3xl md:text-4xl text-white mb-4">{data.name}</h1>
<div>
{data.biography
? data.biography
: intl.formatMessage(messages.nobiography)}
</div>
</div>
</div>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="flex-1 min-w-0">
<div className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<span>{intl.formatMessage(messages.appearsin)}</span>
</div>
</div>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{sortedCast?.map((media) => {
return (
<li
key={`list-cast-item-${media.id}`}
className="col-span-1 flex flex-col text-center items-center"
<div className="text-center text-gray-300 md:text-left">
<h1 className="mb-4 text-3xl text-white md:text-4xl">{data.name}</h1>
<div className="relative">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
className="outline-none group ring-0"
onClick={() => setShowBio((show) => !show)}
role="button"
tabIndex={-1}
>
<TitleCard
id={media.id}
title={media.mediaType === 'movie' ? media.title : media.name}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
<TruncateMarkup
lines={showBio ? 200 : 6}
ellipsis={
<Ellipsis className="relative inline-block ml-2 -top-0.5 opacity-70 group-hover:opacity-100 transition duration-300" />
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.character && (
<div className="mt-2 text-gray-300 text-xs truncate w-36 sm:w-36 md:w-44 text-center">
{intl.formatMessage(messages.ascharacter, {
character: media.character,
})}
>
<div>
{data.biography
? data.biography
: intl.formatMessage(messages.nobiography)}
</div>
)}
</li>
);
})}
{isLoading &&
[...Array(20)].map((_item, i) => (
<li
key={`placeholder-${i}`}
className="col-span-1 flex flex-col text-center items-center"
>
<TitleCard.Placeholder canExpand />
</li>
))}
</ul>
</TruncateMarkup>
</div>
</div>
</div>
</div>
{(sortedCast ?? []).length > 0 && (
<>
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.appearsin)}</span>
</div>
</div>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{sortedCast?.map((media, index) => {
return (
<li
key={`list-cast-item-${media.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<TitleCard
id={media.id}
title={
media.mediaType === 'movie' ? media.title : media.name
}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.character && (
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
{intl.formatMessage(messages.ascharacter, {
character: media.character,
})}
</div>
)}
</li>
);
})}
</ul>
</>
)}
{(sortedCrew ?? []).length > 0 && (
<>
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.crewmember)}</span>
</div>
</div>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{sortedCrew?.map((media, index) => {
return (
<li
key={`list-crew-item-${media.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<TitleCard
id={media.id}
title={
media.mediaType === 'movie' ? media.title : media.name
}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.job && (
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
{media.job}
</div>
)}
</li>
);
})}
</ul>
</>
)}
{isLoading && <LoadingSpinner />}
</>
);
};

View File

@@ -12,23 +12,23 @@ const plexOAuth = new PlexOAuth();
interface PlexLoginButtonProps {
onAuthToken: (authToken: string) => void;
isProcessing?: boolean;
onError?: (message: string) => void;
}
const PlexLoginButton: React.FC<PlexLoginButtonProps> = ({
onAuthToken,
onError,
isProcessing,
}) => {
const intl = useIntl();
const [loading, setLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const getPlexLogin = async () => {
setLoading(true);
try {
const authToken = await plexOAuth.login();
setLoading(false);
setIsProcessing(true);
onAuthToken(authToken);
} catch (e) {
if (onError) {

View File

@@ -43,26 +43,30 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
return (
<div className="block">
<div className="px-4 py-4 sm:px-6 ">
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="mr-6 flex items-center text-sm leading-5 text-gray-300">
<svg
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clipRule="evenodd"
/>
</svg>
<span>{request.requestedBy.username}</span>
<div className="mr-6 flex-col items-center text-sm leading-5 text-gray-300 flex-1 min-w-0">
<div className="flex flex-nowrap mb-1 white">
<svg
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clipRule="evenodd"
/>
</svg>
<span className="truncate w-40 md:w-auto">
{request.requestedBy.username}
</span>
</div>
{request.modifiedBy && (
<>
<div className="flex flex-nowrap">
<svg
className="flex-shrink-0 ml-2 mr-1.5 h-5 w-5 text-gray-300"
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
@@ -74,11 +78,13 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
clipRule="evenodd"
/>
</svg>
<span>{request.modifiedBy?.username}</span>
</>
<span className="truncate w-40 md:w-auto">
{request.modifiedBy?.username}
</span>
</div>
)}
</div>
<div className="ml-2 flex-shrink-0 flex">
<div className="ml-2 flex-shrink-0 flex flex-wrap">
{request.status === MediaRequestStatus.PENDING && (
<>
<span className="mr-1">
@@ -189,13 +195,18 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
</div>
{(request.seasons ?? []).length > 0 && (
<div className="mt-2 text-sm flex items-center">
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
<div className="mt-2 text-sm flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.seasons)}</div>
<div>
{request.seasons.map((season) => (
<span
key={`season-${season.id}`}
className="mr-2 mb-1 inline-block"
>
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
</div>
)}
</div>

View File

@@ -10,7 +10,6 @@ import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({
searchresults: 'Search Results',
@@ -66,25 +65,7 @@ const Search: React.FC = () => {
return (
<>
<div className="md:flex md:items-center md:justify-between mb-6 sm:mb-0 mt-6">
<Header>{intl.formatMessage(messages.searchresults)}</Header>
<div className="mt-4 flex md:mt-0 md:ml-4">
<span className="relative z-0 inline-flex shadow-sm rounded-md">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 rounded-l-md border border-indigo-900 bg-indigo-500 hover:bg-indigo-400 text-sm leading-5 font-medium text-gray-100 hover:text-white focus:z-10 focus:outline-none focus:border-blue-300 focus:ring-blue active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
>
{intl.formatMessage(globalMessages.movies)}
</button>
<button
type="button"
className="-ml-px relative inline-flex items-center px-4 py-2 rounded-r-md border border-indigo-900 bg-indigo-500 text-sm leading-5 font-medium text-gray-100 hover:text-white focus:z-10 focus:outline-none focus:border-blue-300 focus:ring-blue active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
>
{intl.formatMessage(globalMessages.tvshows)}
</button>
</span>
</div>
</div>
<Header>{intl.formatMessage(messages.searchresults)}</Header>
<ListView
items={titles}
isEmpty={isEmpty}

View File

@@ -25,7 +25,10 @@ const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => {
return (
<button
onClick={setCopied}
onClick={(e) => {
e.preventDefault();
setCopied();
}}
className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
>
<svg

View File

@@ -4,8 +4,9 @@ import useSWR from 'swr';
import LoadingSpinner from '../../Common/LoadingSpinner';
import Button from '../../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import Axios from 'axios';
import axios from 'axios';
import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
save: 'Save Changes',
@@ -14,10 +15,15 @@ const messages = defineMessages({
webhookUrl: 'Webhook URL',
validationWebhookUrlRequired: 'You must provide a webhook URL',
webhookUrlPlaceholder: 'Server Settings -> Integrations -> Webhooks',
discordsettingssaved: 'Discord notification settings saved!',
discordsettingsfailed: 'Discord notification settings failed to save.',
testsent: 'Test notification sent!',
test: 'Test',
});
const NotificationsDiscord: React.FC = () => {
const intl = useIntl();
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/discord'
);
@@ -42,21 +48,43 @@ const NotificationsDiscord: React.FC = () => {
validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => {
try {
await Axios.post('/api/v1/settings/notifications/discord', {
await axios.post('/api/v1/settings/notifications/discord', {
enabled: values.enabled,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
},
});
addToast(intl.formatMessage(messages.discordsettingssaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
// TODO show error
addToast(intl.formatMessage(messages.discordsettingsfailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidate();
}
}}
>
{({ errors, touched, isSubmitting }) => {
{({ errors, touched, isSubmitting, values, isValid }) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/discord/test', {
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
},
});
addToast(intl.formatMessage(messages.testsent), {
appearance: 'info',
autoDismiss: true,
});
};
return (
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
@@ -101,11 +129,24 @@ const NotificationsDiscord: React.FC = () => {
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
{intl.formatMessage(messages.test)}
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting}
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.saving)

View File

@@ -4,8 +4,9 @@ import useSWR from 'swr';
import LoadingSpinner from '../../Common/LoadingSpinner';
import Button from '../../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import Axios from 'axios';
import axios from 'axios';
import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
save: 'Save Changes',
@@ -20,10 +21,18 @@ const messages = defineMessages({
enableSsl: 'Enable SSL',
authUser: 'Auth User',
authPass: 'Auth Pass',
emailsettingssaved: 'Email notification settings saved!',
emailsettingsfailed: 'Email notification settings failed to save.',
test: 'Test',
testsent: 'Test notification sent!',
allowselfsigned: 'Allow Self-Signed Certificates',
ssldisabletip:
'SSL should be disabled on standard TLS connections (Port 587)',
});
const NotificationsEmail: React.FC = () => {
const intl = useIntl();
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/email'
);
@@ -55,35 +64,64 @@ const NotificationsEmail: React.FC = () => {
secure: data.options.secure,
authUser: data.options.authUser,
authPass: data.options.authPass,
allowSelfSigned: data.options.allowSelfSigned,
}}
validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => {
try {
await Axios.post('/api/v1/settings/notifications/email', {
await axios.post('/api/v1/settings/notifications/email', {
enabled: values.enabled,
types: values.types,
options: {
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
smtpPort: values.smtpPort,
smtpPort: Number(values.smtpPort),
secure: values.secure,
authUser: values.authUser,
authPass: values.authPass,
allowSelfSigned: values.allowSelfSigned,
},
});
addToast(intl.formatMessage(messages.emailsettingssaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
// TODO show error
addToast(intl.formatMessage(messages.emailsettingsfailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidate();
}
}}
>
{({ errors, touched, isSubmitting }) => {
{({ errors, touched, isSubmitting, values, isValid }) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/email/test', {
enabled: true,
types: values.types,
options: {
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
smtpPort: Number(values.smtpPort),
secure: values.secure,
authUser: values.authUser,
authPass: values.authPass,
},
});
addToast(intl.formatMessage(messages.testsent), {
appearance: 'info',
autoDismiss: true,
});
};
return (
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label
htmlFor="isDefault"
htmlFor="enabled"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.agentenabled)}
@@ -93,135 +131,169 @@ const NotificationsEmail: React.FC = () => {
type="checkbox"
id="enabled"
name="enabled"
className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="name"
htmlFor="emailFrom"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.emailsender)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="emailFrom"
name="emailFrom"
type="text"
placeholder="no-reply@example.com"
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.emailFrom && touched.emailFrom && (
<div className="text-red-500 mt-2">{errors.emailFrom}</div>
<div className="mt-2 text-red-500">{errors.emailFrom}</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="name"
htmlFor="smtpHost"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.smtpHost)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="smtpHost"
name="smtpHost"
type="text"
placeholder="localhost"
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.smtpHost && touched.smtpHost && (
<div className="text-red-500 mt-2">{errors.smtpHost}</div>
<div className="mt-2 text-red-500">{errors.smtpHost}</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="name"
htmlFor="smtpPort"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.smtpPort)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="smtpPort"
name="smtpPort"
type="text"
placeholder="465"
className="form-input block w-24 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="block w-24 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.smtpPort && touched.smtpPort && (
<div className="text-red-500 mt-2">{errors.smtpPort}</div>
<div className="mt-2 text-red-500">{errors.smtpPort}</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label
htmlFor="isDefault"
htmlFor="secure"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.enableSsl)}
<div className="flex flex-col">
<span>{intl.formatMessage(messages.enableSsl)}</span>
<span className="text-gray-500">
{intl.formatMessage(messages.ssldisabletip)}
</span>
</div>
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="secure"
name="secure"
className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label
htmlFor="allowSelfSigned"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.allowselfsigned)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="allowSelfSigned"
name="allowSelfSigned"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="name"
htmlFor="authUser"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.authUser)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="authUser"
name="authUser"
type="text"
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="name"
htmlFor="authPass"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.authPass)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="authPass"
name="authPass"
type="password"
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
{intl.formatMessage(messages.test)}
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting}
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.saving)

View File

@@ -11,11 +11,13 @@ import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
createradarr: 'Create New Radarr Server',
editradarr: 'Edit Radarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a hostname/IP',
validationPortRequired: 'You must provide a port',
validationApiKeyRequired: 'You must provide an API key',
validationRootFolderRequired: 'You must select a root folder',
validationProfileRequired: 'You must select a profile',
validationMinimumAvailabilityRequired: 'You must select minimum availability',
toastRadarrTestSuccess: 'Radarr connection established!',
toastRadarrTestFailure: 'Failed to connect to Radarr Server',
saving: 'Saving...',
@@ -40,6 +42,10 @@ const messages = defineMessages({
selectQualityProfile: 'Select a Quality Profile',
selectRootFolder: 'Select a Root Folder',
selectMinimumAvailability: 'Select minimum availability',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test your connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test your connection to load root folders',
});
interface TestResponse {
@@ -74,6 +80,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
rootFolders: [],
});
const RadarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
),
hostname: Yup.string().required(
intl.formatMessage(messages.validationHostnameRequired)
),
@@ -81,10 +90,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
intl.formatMessage(messages.validationPortRequired)
),
apiKey: Yup.string().required(intl.formatMessage(messages.apiKey)),
rootFolder: Yup.string().required(intl.formatMessage(messages.rootfolder)),
rootFolder: Yup.string().required(
intl.formatMessage(messages.validationRootFolderRequired)
),
activeProfileId: Yup.string().required(
intl.formatMessage(messages.validationProfileRequired)
),
minimumAvailability: Yup.string().required(
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
),
});
const testConnection = useCallback(
@@ -108,7 +122,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
{
hostname,
apiKey,
port,
port: Number(port),
baseUrl,
useSsl,
}
@@ -171,7 +185,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
baseUrl: radarr?.baseUrl,
activeProfileId: radarr?.activeProfileId,
rootFolder: radarr?.activeDirectory,
minimumAvailability: radarr?.minimumAvailability,
minimumAvailability: radarr?.minimumAvailability ?? 'released',
isDefault: radarr?.isDefault ?? false,
is4k: radarr?.is4k ?? false,
}}
@@ -185,11 +199,11 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
const submission = {
name: values.name,
hostname: values.hostname,
port: values.port,
port: Number(values.port),
apiKey: values.apiKey,
useSsl: values.ssl,
baseUrl: values.baseUrl,
activeProfileId: values.activeProfileId,
activeProfileId: Number(values.activeProfileId),
activeProfileName: profileName,
activeDirectory: values.rootFolder,
is4k: values.is4k,
@@ -218,6 +232,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
return (
<Modal
@@ -250,7 +265,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
secondaryDisabled={
!values.apiKey || !values.hostname || !values.port || isTesting
}
okDisabled={!isValidated || isSubmitting || isTesting}
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
onOk={() => handleSubmit()}
title={
!radarr
@@ -312,6 +327,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-500 bg-gray-600 text-gray-100 sm:text-sm cursor-default">
{values.ssl ? 'https://' : 'http://'}
</span>
<Field
id="hostname"
name="hostname"
@@ -321,7 +339,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
setIsValidated(false);
setFieldValue('hostname', e.target.value);
}}
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
/>
</div>
{errors.hostname && touched.hostname && (
@@ -442,10 +460,17 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
as="select"
id="activeProfileId"
name="activeProfileId"
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
disabled={!isValidated || isTesting}
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="">
{intl.formatMessage(messages.selectQualityProfile)}
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
@@ -478,10 +503,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
as="select"
id="rootFolder"
name="rootFolder"
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
disabled={!isValidated || isTesting}
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="">
{intl.formatMessage(messages.selectRootFolder)}
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(messages.testFirstRootFolders)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
@@ -516,17 +546,18 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
name="minimumAvailability"
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
>
<option value="">
{intl.formatMessage(
messages.selectMinimumAvailability
)}
</option>
<option value="announced">Announced</option>
<option value="inCinemas">In Cinemas</option>
<option value="released">Released</option>
<option value="preDB">PreDB</option>
</Field>
</div>
{errors.minimumAvailability &&
touched.minimumAvailability && (
<div className="text-red-500 mt-2">
{errors.minimumAvailability}
</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">

View File

@@ -0,0 +1,186 @@
import React, { useState } from 'react';
import useSWR from 'swr';
import ReactMarkdown from 'react-markdown';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import Alert from '../../../Common/Alert';
import Badge from '../../../Common/Badge';
import Button from '../../../Common/Button';
import Modal from '../../../Common/Modal';
import Transition from '../../../Transition';
import { defineMessages, useIntl } from 'react-intl';
import globalMessages from '../../../../i18n/globalMessages';
const messages = defineMessages({
releases: 'Releases',
releasedataMissing: 'Release data missing. Is GitHub down?',
versionChangelog: 'Version Changelog',
viewongithub: 'View on GitHub',
latestversion: 'Latest Version',
currentversion: 'Current Version',
viewchangelog: 'View Changelog',
runningDevelop: 'You are running a develop version of Overseerr!',
runningDevelopMessage:
'The changes in your version will not be available below. Please look at the <GithubLink>GitHub repository</GithubLink> for latest updates.',
});
const REPO_RELEASE_API =
'https://api.github.com/repos/sct/overseerr/releases?per_page=20';
interface GitHubRelease {
url: string;
assets_url: string;
upload_url: string;
html_url: string;
id: number;
node_id: string;
tag_name: string;
target_commitish: string;
name: string;
draft: boolean;
prerelease: boolean;
created_at: string;
published_at: string;
tarball_url: string;
zipball_url: string;
body: string;
}
interface ReleaseProps {
release: GitHubRelease;
isLatest: boolean;
currentVersion: string;
}
const Release: React.FC<ReleaseProps> = ({
currentVersion,
release,
isLatest,
}) => {
const intl = useIntl();
const [isModalOpen, setModalOpen] = useState(false);
return (
<div className="bg-gray-800 rounded-md flex flex-col sm:flex-row px-4 py-2">
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={isModalOpen}
>
<Modal
onCancel={() => setModalOpen(false)}
iconSvg={
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
}
title={intl.formatMessage(messages.versionChangelog)}
cancelText={intl.formatMessage(globalMessages.close)}
okText={intl.formatMessage(messages.viewongithub)}
onOk={() => {
window.open(release.html_url, '_blank');
}}
>
<div className="prose">
<ReactMarkdown>{release.body}</ReactMarkdown>
</div>
</Modal>
</Transition>
<div className="flex mb-4 sm:mb-0 items-center justify-center sm:justify-start">
<span className="text-xl">{release.name}</span>
{isLatest && (
<span className="ml-2">
<Badge badgeType="primary">
{intl.formatMessage(messages.latestversion)}
</Badge>
</span>
)}
{release.name.includes(currentVersion) && (
<span className="ml-2">
<Badge badgeType="success">
{intl.formatMessage(messages.currentversion)}
</Badge>
</span>
)}
</div>
<div className="flex-1 text-center sm:text-right">
<Button buttonType="primary" onClick={() => setModalOpen(true)}>
{intl.formatMessage(messages.viewchangelog)}
</Button>
</div>
</div>
);
};
interface ReleasesProps {
currentVersion: string;
}
const Releases: React.FC<ReleasesProps> = ({ currentVersion }) => {
const intl = useIntl();
const { data, error } = useSWR<GitHubRelease[]>(REPO_RELEASE_API);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return (
<div className="text-gray-300">
{intl.formatMessage(messages.releasedataMissing)}
</div>
);
}
return (
<div>
<div className="text-xl pb-4 mb-4 border-b border-gray-800">
{intl.formatMessage(messages.releases)}
</div>
{currentVersion.startsWith('develop-') && (
<Alert title={intl.formatMessage(messages.runningDevelop)}>
{intl.formatMessage(messages.runningDevelopMessage, {
GithubLink: function GithubLink(msg) {
return (
<a
href="https://github.com/sct/overseerr"
target="_blank"
rel="noreferrer"
className="text-yellow-100 underline hover:text-white transition duration-300"
>
{msg}
</a>
);
},
})}
</Alert>
)}
{data?.map((release, index) => {
return (
<div key={`release-${release.id}`} className="mb-2">
<Release
release={release}
currentVersion={currentVersion}
isLatest={index === 0}
/>
</div>
);
})}
</div>
);
};
export default Releases;

View File

@@ -0,0 +1,104 @@
import React from 'react';
import useSWR from 'swr';
import Error from '../../../pages/_error';
import List from '../../Common/List';
import LoadingSpinner from '../../Common/LoadingSpinner';
import { SettingsAboutResponse } from '../../../../server/interfaces/api/settingsInterfaces';
import { defineMessages, FormattedNumber, useIntl } from 'react-intl';
import Releases from './Releases';
const messages = defineMessages({
overseerrinformation: 'Overseerr Information',
version: 'Version',
totalmedia: 'Total Media',
totalrequests: 'Total Requests',
gettingsupport: 'Getting Support',
githubdiscussions: 'GitHub Discussions',
clickheretojoindiscord: 'Click here to join our Discord server.',
timezone: 'Timezone',
supportoverseerr: 'Support Overseerr',
helppaycoffee: 'Help pay for coffee',
});
const SettingsAbout: React.FC = () => {
const intl = useIntl();
const { data, error } = useSWR<SettingsAboutResponse>(
'/api/v1/settings/about'
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={500} />;
}
return (
<>
<div className="mb-8">
<List title={intl.formatMessage(messages.overseerrinformation)}>
<List.Item title={intl.formatMessage(messages.version)}>
{data.version}
</List.Item>
<List.Item title={intl.formatMessage(messages.totalmedia)}>
<FormattedNumber value={data.totalMediaItems} />
</List.Item>
<List.Item title={intl.formatMessage(messages.totalrequests)}>
<FormattedNumber value={data.totalRequests} />
</List.Item>
{data.tz && (
<List.Item title={intl.formatMessage(messages.timezone)}>
{data.tz}
</List.Item>
)}
</List>
</div>
<div className="mb-8">
<List title={intl.formatMessage(messages.gettingsupport)}>
<List.Item title={intl.formatMessage(messages.githubdiscussions)}>
<a
href="https://github.com/sct/overseerr/discussions"
target="_blank"
rel="noreferrer"
className="text-indigo-500 hover:underline"
>
https://github.com/sct/overseerr/discussions
</a>
</List.Item>
<List.Item title="Discord">
<a
href="https://discord.gg/PkCWJSeCk7"
target="_blank"
rel="noreferrer"
className="text-indigo-500 hover:underline"
>
{intl.formatMessage(messages.clickheretojoindiscord)}
</a>
</List.Item>
</List>
</div>
<div className="mb-8">
<List title={intl.formatMessage(messages.supportoverseerr)}>
<List.Item
title={`☕️ ${intl.formatMessage(messages.helppaycoffee)}`}
>
<a
href="https://patreon.com/overseerr"
target="_blank"
rel="noreferrer"
className="text-indigo-500 hover:underline"
>
https://patreon.com/overseerr
</a>
</List.Item>
</List>
</div>
<div className="mb-8">
<Releases currentVersion={data.version} />
</div>
</>
);
};
export default SettingsAbout;

View File

@@ -46,7 +46,7 @@ const SettingsJobs: React.FC = () => {
</div>
</Table.TD>
<Table.TD alignText="right">
<Button buttonType="primary">
<Button buttonType="primary" disabled>
{intl.formatMessage(messages.runnow)}
</Button>
</Table.TD>

View File

@@ -0,0 +1,17 @@
import React from 'react';
// We will localize this file when the complete version is released.
const SettingsLogs: React.FC = () => {
return (
<>
<div className="leading-loose text-gray-300 text-sm">
Logs page is still being built. For now, you can access your logs
directly in <code>stdout</code> (container logs) or looking in{' '}
<code>/app/config/logs/overseerr.logs</code>
</div>
</>
);
};
export default SettingsLogs;

View File

@@ -8,6 +8,9 @@ import axios from 'axios';
import Button from '../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import { useUser, Permission } from '../../hooks/useUser';
import { useToasts } from 'react-toast-notifications';
import { messages as permissionMessages } from '../UserEdit';
import { hasPermission } from '../../../server/lib/permissions';
const messages = defineMessages({
generalsettings: 'General Settings',
@@ -17,26 +20,105 @@ const messages = defineMessages({
saving: 'Saving...',
apikey: 'API Key',
applicationurl: 'Application URL',
toastApiKeySuccess: 'New API Key generated!',
toastApiKeyFailure: 'Something went wrong generating a new API Key.',
toastSettingsSuccess: 'Settings saved.',
toastSettingsFailure: 'Something went wrong saving settings.',
defaultPermissions: 'Default User Permissions',
});
interface PermissionOption {
id: string;
name: string;
description: string;
permission: Permission;
}
const SettingsMain: React.FC = () => {
const { hasPermission } = useUser();
const { addToast } = useToasts();
const { hasPermission: userHasPermission } = useUser();
const intl = useIntl();
const { data, error, revalidate } = useSWR<MainSettings>(
'/api/v1/settings/main'
);
const regenerate = async () => {
try {
await axios.get('/api/v1/settings/main/regenerate');
revalidate();
addToast(intl.formatMessage(messages.toastApiKeySuccess), {
autoDismiss: true,
appearance: 'success',
});
} catch (e) {
addToast(intl.formatMessage(messages.toastApiKeyFailure), {
autoDismiss: true,
appearance: 'error',
});
}
};
if (!data && !error) {
return <LoadingSpinner />;
}
const permissionList: PermissionOption[] = [
{
id: 'admin',
name: intl.formatMessage(permissionMessages.admin),
description: intl.formatMessage(permissionMessages.adminDescription),
permission: Permission.ADMIN,
},
{
id: 'settings',
name: intl.formatMessage(permissionMessages.settings),
description: intl.formatMessage(permissionMessages.settingsDescription),
permission: Permission.MANAGE_SETTINGS,
},
{
id: 'users',
name: intl.formatMessage(permissionMessages.users),
description: intl.formatMessage(permissionMessages.usersDescription),
permission: Permission.MANAGE_USERS,
},
{
id: 'managerequest',
name: intl.formatMessage(permissionMessages.managerequests),
description: intl.formatMessage(
permissionMessages.managerequestsDescription
),
permission: Permission.MANAGE_REQUESTS,
},
{
id: 'request',
name: intl.formatMessage(permissionMessages.request),
description: intl.formatMessage(permissionMessages.requestDescription),
permission: Permission.REQUEST,
},
{
id: 'vote',
name: intl.formatMessage(permissionMessages.vote),
description: intl.formatMessage(permissionMessages.voteDescription),
permission: Permission.VOTE,
},
{
id: 'autoapprove',
name: intl.formatMessage(permissionMessages.autoapprove),
description: intl.formatMessage(
permissionMessages.autoapproveDescription
),
permission: Permission.AUTO_APPROVE,
},
];
return (
<>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-200">
{intl.formatMessage(messages.generalsettings)}
</h3>
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
{intl.formatMessage(messages.generalsettingsDescription)}
</p>
</div>
@@ -44,23 +126,34 @@ const SettingsMain: React.FC = () => {
<Formik
initialValues={{
applicationUrl: data?.applicationUrl,
defaultPermissions: data?.defaultPermissions ?? 0,
}}
enableReinitialize
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/main', {
applicationUrl: values.applicationUrl,
defaultPermissions: values.defaultPermissions,
});
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
autoDismiss: true,
appearance: 'success',
});
} catch (e) {
// TODO show error
addToast(intl.formatMessage(messages.toastSettingsFailure), {
autoDismiss: true,
appearance: 'error',
});
} finally {
revalidate();
}
}}
>
{({ isSubmitting }) => {
{({ isSubmitting, values, setFieldValue }) => {
return (
<Form>
{hasPermission(Permission.ADMIN) && (
{userHasPermission(Permission.ADMIN) && (
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="username"
@@ -69,16 +162,25 @@ const SettingsMain: React.FC = () => {
{intl.formatMessage(messages.apikey)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<div className="flex max-w-lg rounded-md shadow-sm">
<input
type="text"
id="apiKey"
className="flex-1 form-input block w-full min-w-0 rounded-none rounded-l-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-none form-input rounded-l-md sm:text-sm sm:leading-5"
value={data?.apiKey}
readOnly
/>
<CopyButton textToCopy={data?.apiKey ?? ''} />
<button className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium rounded-r-md text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150">
<CopyButton
textToCopy={data?.apiKey ?? ''}
key={data?.apiKey}
/>
<button
onClick={(e) => {
e.preventDefault();
regenerate();
}}
className="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-500 border border-gray-500 rounded-r-md hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700"
>
<svg
className="w-5 h-5"
fill="currentColor"
@@ -104,20 +206,98 @@ const SettingsMain: React.FC = () => {
{intl.formatMessage(messages.applicationurl)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="applicationUrl"
name="applicationUrl"
type="text"
placeholder="https://os.example.com"
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-permissions"
>
{intl.formatMessage(messages.defaultPermissions)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
{permissionList.map((permissionOption) => (
<div
className={`relative flex items-start first:mt-0 mt-4 ${
permissionOption.permission !==
Permission.ADMIN &&
hasPermission(
Permission.ADMIN,
values.defaultPermissions
)
? 'opacity-50'
: ''
}`}
key={`permission-option-${permissionOption.id}`}
>
<div className="flex items-center h-5">
<input
id={permissionOption.id}
name="permissions"
type="checkbox"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
permissionOption.permission !==
Permission.ADMIN &&
hasPermission(
Permission.ADMIN,
values.defaultPermissions
)
}
onClick={() => {
setFieldValue(
'defaultPermissions',
hasPermission(
permissionOption.permission,
values.defaultPermissions
)
? values.defaultPermissions -
permissionOption.permission
: values.defaultPermissions +
permissionOption.permission
);
}}
checked={hasPermission(
permissionOption.permission,
values.defaultPermissions
)}
/>
</div>
<div className="ml-3 text-sm leading-5">
<label
htmlFor={permissionOption.id}
className="font-medium"
>
{permissionOption.name}
</label>
<p className="text-gray-500">
{permissionOption.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

View File

@@ -2,21 +2,23 @@ import React, { useState } from 'react';
import LoadingSpinner from '../Common/LoadingSpinner';
import type { PlexSettings } from '../../../server/lib/settings';
import useSWR from 'swr';
import { useFormik } from 'formik';
import { Formik, Field } from 'formik';
import Button from '../Common/Button';
import axios from 'axios';
import LibraryItem from './LibraryItem';
import Badge from '../Common/Badge';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import * as Yup from 'yup';
const messages = defineMessages({
plexsettings: 'Plex Settings',
plexsettingsDescription:
'Configure the settings for your Plex server. Overseerr uses your Plex server to scan your library at an interval and see what content is available.',
servername: 'Server Name (Automatically Set)',
servername: 'Server Name (Automatically set after you save)',
servernamePlaceholder: 'Plex Server Name',
hostname: 'Hostname/IP',
port: 'Port',
ssl: 'SSL',
save: 'Save Changes',
saving: 'Saving...',
plexlibraries: 'Plex Libraries',
@@ -26,12 +28,14 @@ const messages = defineMessages({
sync: 'Sync Plex Libraries',
manualscan: 'Manual Library Scan',
manualscanDescription:
"Normally, this will only be run once every 6 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one time full manual library scan is recommended!",
"Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one time full manual library scan is recommended!",
notrunning: 'Not Running',
currentlibrary: 'Current Library: {name}',
librariesRemaining: 'Libraries Remaining: {count}',
startscan: 'Start Scan',
cancelscan: 'Cancel Scan',
validationHostnameRequired: 'You must provide a hostname/IP',
validationPortRequired: 'You must provide a port',
});
interface Library {
@@ -64,33 +68,15 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
}
);
const [isSyncing, setIsSyncing] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const formik = useFormik({
initialValues: {
hostname: data?.ip,
port: data?.port,
},
enableReinitialize: true,
onSubmit: async (values) => {
setSubmitError(null);
setIsUpdating(true);
try {
await axios.post('/api/v1/settings/plex', {
ip: values.hostname,
port: values.port,
} as PlexSettings);
revalidate();
if (onComplete) {
onComplete();
}
} catch (e) {
setSubmitError(e.response.data.message);
} finally {
setIsUpdating(false);
}
},
const PlexSettingsSchema = Yup.object().shape({
hostname: Yup.string().required(
intl.formatMessage(messages.validationHostnameRequired)
),
port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired)
),
});
const activeLibraries =
@@ -157,103 +143,169 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
return (
<>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-200">
<FormattedMessage {...messages.plexsettings} />
</h3>
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
<FormattedMessage {...messages.plexsettingsDescription} />
</p>
</div>
<form onSubmit={formik.handleSubmit}>
<div className="mt-6 sm:mt-5">
{submitError && (
<div className="bg-red-700 text-white p-4 rounded-md mb-6">
{submitError}
</div>
)}
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
<FormattedMessage {...messages.servername} />
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<input
type="text"
id="name"
name="name"
placeholder={intl.formatMessage(
messages.servernamePlaceholder
)}
value={data?.name}
readOnly
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
/>
<Formik
initialValues={{
hostname: data?.ip,
port: data?.port,
useSsl: data?.useSsl,
}}
enableReinitialize
validationSchema={PlexSettingsSchema}
onSubmit={async (values) => {
setSubmitError(null);
try {
await axios.post('/api/v1/settings/plex', {
ip: values.hostname,
port: Number(values.port),
useSsl: values.useSsl,
} as PlexSettings);
revalidate();
if (onComplete) {
onComplete();
}
} catch (e) {
setSubmitError(e.response.data.message);
}
}}
>
{({
errors,
touched,
values,
handleSubmit,
setFieldValue,
isSubmitting,
}) => {
return (
<form onSubmit={handleSubmit}>
<div className="mt-6 sm:mt-5">
{submitError && (
<div className="p-4 mb-6 text-white bg-red-700 rounded-md">
{submitError}
</div>
)}
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
<FormattedMessage {...messages.servername} />
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<input
type="text"
id="name"
name="name"
placeholder={intl.formatMessage(
messages.servernamePlaceholder
)}
value={data?.name}
readOnly
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
</div>
</div>
<div className="mt-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="hostname"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
<FormattedMessage {...messages.hostname} />
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
{values.useSsl ? 'https://' : 'http://'}
</span>
<Field
type="text"
id="hostname"
name="hostname"
placeholder="127.0.0.1"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 form-input rounded-r-md sm:text-sm sm:leading-5"
/>
</div>
{errors.hostname && touched.hostname && (
<div className="mt-2 text-red-500">{errors.hostname}</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label
htmlFor="port"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
<FormattedMessage {...messages.port} />
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg rounded-md shadow-sm sm:max-w-xs">
<Field
type="text"
id="port"
name="port"
placeholder="32400"
className="block w-24 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.port && touched.port && (
<div className="mt-2 text-red-500">{errors.port}</div>
)}
</div>
</div>
</div>
</div>
</div>
<div className="mt-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="hostname"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
<FormattedMessage {...messages.hostname} />
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<input
type="text"
id="hostname"
name="hostname"
placeholder="127.0.0.1"
value={formik.values.hostname}
onChange={formik.handleChange}
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
/>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label
htmlFor="ssl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.ssl)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="useSsl"
name="useSsl"
onChange={() => {
setFieldValue('useSsl', !values.useSsl);
}}
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label
htmlFor="port"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
<FormattedMessage {...messages.port} />
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg rounded-md shadow-sm sm:max-w-xs">
<input
type="text"
id="port"
name="port"
placeholder="32400"
value={formik.values.port}
onChange={formik.handleChange}
className="form-input block w-24 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
/>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? intl.formatMessage(messages.saving)
: intl.formatMessage(messages.save)}
</Button>
</span>
</div>
</div>
</div>
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button buttonType="primary" type="submit" disabled={isUpdating}>
{isUpdating
? intl.formatMessage(messages.saving)
: intl.formatMessage(messages.save)}
</Button>
</span>
</div>
</div>
</form>
</form>
);
}}
</Formik>
<div className="mt-10">
<h3 className="text-lg leading-6 font-medium text-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-200">
<FormattedMessage {...messages.plexlibraries} />
</h3>
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
<FormattedMessage {...messages.plexlibrariesDescription} />
</p>
<div className="mt-6">
@@ -275,7 +327,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
: intl.formatMessage(messages.sync)}
</Button>
</div>
<ul className="mt-6 grid grid-cols-1 gap-5 sm:gap-6 sm:grid-cols-2 lg:grid-cols-4">
<ul className="grid grid-cols-1 gap-5 mt-6 sm:gap-6 sm:grid-cols-2 lg:grid-cols-4">
{data?.libraries.map((library) => (
<LibraryItem
name={library.name}
@@ -287,18 +339,18 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</ul>
</div>
<div className="mt-10">
<h3 className="text-lg leading-6 font-medium text-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-200">
<FormattedMessage {...messages.manualscan} />
</h3>
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
<FormattedMessage {...messages.manualscanDescription} />
</p>
<div className="mt-6">
<div className="bg-gray-800 p-4 rounded-md">
<div className="w-full h-8 rounded-full bg-gray-600 mb-6 relative overflow-hidden">
<div className="p-4 bg-gray-800 rounded-md">
<div className="relative w-full h-8 mb-6 overflow-hidden bg-gray-600 rounded-full">
{dataSync?.running && (
<div
className="h-8 bg-indigo-600 transition-all ease-in-out duration-200"
className="h-8 transition-all duration-200 ease-in-out bg-indigo-600"
style={{
width: `${Math.round(
(dataSync.progress / dataSync.total) * 100
@@ -306,7 +358,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
}}
/>
)}
<div className="absolute inset-0 text-sm w-full h-8 flex items-center justify-center">
<div className="absolute inset-0 flex items-center justify-center w-full h-8 text-sm">
<span>
{dataSync?.running
? `${dataSync.progress} of ${dataSync.total}`
@@ -314,10 +366,10 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</span>
</div>
</div>
<div className="flex w-full flex-col sm:flex-row">
<div className="flex flex-col w-full sm:flex-row">
{dataSync?.running && (
<>
<div className="flex items-center mr-0 mb-2 sm:mb-0 sm:mr-2">
<div className="flex items-center mb-2 mr-0 sm:mb-0 sm:mr-2">
<Badge>
<FormattedMessage
{...messages.currentlibrary}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import useSWR from 'swr';
@@ -13,6 +13,7 @@ import Modal from '../Common/Modal';
import Transition from '../Transition';
import axios from 'axios';
import SonarrModal from './SonarrModal';
import Alert from '../Common/Alert';
const messages = defineMessages({
radarrsettings: 'Radarr Settings',
@@ -31,6 +32,10 @@ const messages = defineMessages({
activeProfile: 'Active Profile',
addradarr: 'Add Radarr Server',
addsonarr: 'Add Sonarr Server',
nodefault: 'No default server selected!',
nodefaultdescription:
'At least one server must be marked as default before any requests will make it to your services.',
no4kimplemented: '(Default 4K servers are not currently implemented)',
});
interface ServerInstanceProps {
@@ -94,7 +99,7 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
</p>
</div>
<img
className="w-10 h-10 bg-gray-300 rounded-full flex-shrink-0"
className="w-10 h-10 flex-shrink-0"
src={`/images/${isSonarr ? 'sonarr' : 'radarr'}_logo.png`}
alt=""
/>
@@ -148,6 +153,7 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
};
const SettingsServices: React.FC = () => {
const intl = useIntl();
const {
data: radarrData,
error: radarrError,
@@ -249,51 +255,64 @@ const SettingsServices: React.FC = () => {
<div className="mt-6 sm:mt-5">
{!radarrData && !radarrError && <LoadingSpinner />}
{radarrData && !radarrError && (
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{radarrData.map((radarr) => (
<ServerInstance
key={`radarr-config-${radarr.id}`}
name={radarr.name}
address={radarr.hostname}
profileName={radarr.activeProfileName}
isSSL={radarr.useSsl}
isDefault={radarr.isDefault && !radarr.is4k}
isDefault4K={radarr.is4k && radarr.isDefault}
onEdit={() => setEditRadarrModal({ open: true, radarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: radarr.id,
type: 'radarr',
})
}
/>
))}
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
<div className="flex items-center justify-center w-full h-full">
<Button
buttonType="ghost"
onClick={() =>
setEditRadarrModal({ open: true, radarr: null })
<>
{radarrData.length > 0 &&
!radarrData.some(
(radarr) => radarr.isDefault && !radarr.is4k
) && (
<Alert title={intl.formatMessage(messages.nodefault)}>
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
<p className="mt-2">
{intl.formatMessage(messages.no4kimplemented)}
</p>
</Alert>
)}
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{radarrData.map((radarr) => (
<ServerInstance
key={`radarr-config-${radarr.id}`}
name={radarr.name}
address={radarr.hostname}
profileName={radarr.activeProfileName}
isSSL={radarr.useSsl}
isDefault={radarr.isDefault && !radarr.is4k}
isDefault4K={radarr.is4k && radarr.isDefault}
onEdit={() => setEditRadarrModal({ open: true, radarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: radarr.id,
type: 'radarr',
})
}
>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
/>
))}
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
<div className="flex items-center justify-center w-full h-full">
<Button
buttonType="ghost"
onClick={() =>
setEditRadarrModal({ open: true, radarr: null })
}
>
<path
fillRule="evenodd"
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage {...messages.addradarr} />
</Button>
</div>
</li>
</ul>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage {...messages.addradarr} />
</Button>
</div>
</li>
</ul>
</>
)}
</div>
<div className="mt-10">
@@ -307,52 +326,65 @@ const SettingsServices: React.FC = () => {
<div className="mt-6 sm:mt-5">
{!sonarrData && !sonarrError && <LoadingSpinner />}
{sonarrData && !sonarrError && (
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{sonarrData.map((sonarr) => (
<ServerInstance
key={`sonarr-config-${sonarr.id}`}
name={sonarr.name}
address={sonarr.hostname}
profileName={sonarr.activeProfileName}
isSSL={sonarr.useSsl}
isSonarr
isDefault4K={sonarr.isDefault && sonarr.is4k}
isDefault={sonarr.isDefault && !sonarr.is4k}
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: sonarr.id,
type: 'sonarr',
})
}
/>
))}
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
<div className="flex items-center justify-center w-full h-full">
<Button
buttonType="ghost"
onClick={() =>
setEditSonarrModal({ open: true, sonarr: null })
<>
{sonarrData.length > 0 &&
!sonarrData.some(
(sonarr) => sonarr.isDefault && !sonarr.is4k
) && (
<Alert title={intl.formatMessage(messages.nodefault)}>
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
<p className="mt-2">
{intl.formatMessage(messages.no4kimplemented)}
</p>
</Alert>
)}
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{sonarrData.map((sonarr) => (
<ServerInstance
key={`sonarr-config-${sonarr.id}`}
name={sonarr.name}
address={sonarr.hostname}
profileName={sonarr.activeProfileName}
isSSL={sonarr.useSsl}
isSonarr
isDefault4K={sonarr.isDefault && sonarr.is4k}
isDefault={sonarr.isDefault && !sonarr.is4k}
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: sonarr.id,
type: 'sonarr',
})
}
>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
/>
))}
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
<div className="flex items-center justify-center w-full h-full">
<Button
buttonType="ghost"
onClick={() =>
setEditSonarrModal({ open: true, sonarr: null })
}
>
<path
fillRule="evenodd"
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage {...messages.addsonarr} />
</Button>
</div>
</li>
</ul>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage {...messages.addsonarr} />
</Button>
</div>
</li>
</ul>
</>
)}
</div>
</>

View File

@@ -11,6 +11,7 @@ import { useIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
createsonarr: 'Create New Sonarr Server',
editsonarr: 'Edit Sonarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a hostname/IP',
validationPortRequired: 'You must provide a port',
validationApiKeyRequired: 'You must provide an API key',
@@ -35,10 +36,16 @@ const messages = defineMessages({
baseUrlPlaceholder: 'Example: /sonarr',
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
animequalityprofile: 'Anime Quality Profile',
animerootfolder: 'Anime Root Folder',
seasonfolders: 'Season Folders',
server4k: '4K Server',
selectQualityProfile: 'Select a Quality Profile',
selectRootFolder: 'Select a Root Folder',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test your connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test your connection to load root folders',
});
interface TestResponse {
@@ -73,6 +80,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
rootFolders: [],
});
const SonarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
),
hostname: Yup.string().required(
intl.formatMessage(messages.validationHostnameRequired)
),
@@ -111,7 +121,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
{
hostname,
apiKey,
port,
port: Number(port),
baseUrl,
useSsl,
}
@@ -174,6 +184,8 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
baseUrl: sonarr?.baseUrl,
activeProfileId: sonarr?.activeProfileId,
rootFolder: sonarr?.activeDirectory,
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false,
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
@@ -184,17 +196,25 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
const profileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeProfileId)
)?.name;
const animeProfileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeAnimeProfileId)
)?.name;
const submission = {
name: values.name,
hostname: values.hostname,
port: values.port,
port: Number(values.port),
apiKey: values.apiKey,
useSsl: values.ssl,
baseUrl: values.baseUrl,
activeProfileId: values.activeProfileId,
activeProfileId: Number(values.activeProfileId),
activeProfileName: profileName,
activeDirectory: values.rootFolder,
activeAnimeProfileId: values.activeAnimeProfileId
? Number(values.activeAnimeProfileId)
: undefined,
activeAnimeProfileName: animeProfileName ?? undefined,
activeAnimeDirectory: values.activeAnimeRootFolder,
is4k: values.is4k,
isDefault: values.isDefault,
enableSeasonFolders: values.enableSeasonFolders,
@@ -221,6 +241,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
return (
<Modal
@@ -253,7 +274,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
secondaryDisabled={
!values.apiKey || !values.hostname || !values.port || isTesting
}
okDisabled={!isValidated || isSubmitting || isTesting}
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
onOk={() => handleSubmit()}
title={
!sonarr
@@ -315,6 +336,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-500 bg-gray-600 text-gray-100 sm:text-sm cursor-default">
{values.ssl ? 'https://' : 'http://'}
</span>
<Field
id="hostname"
name="hostname"
@@ -324,7 +348,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
setIsValidated(false);
setFieldValue('hostname', e.target.value);
}}
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
/>
</div>
{errors.hostname && touched.hostname && (
@@ -445,10 +469,17 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
as="select"
id="activeProfileId"
name="activeProfileId"
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
disabled={!isValidated || isTesting}
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="">
{intl.formatMessage(messages.selectQualityProfile)}
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
@@ -481,10 +512,15 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
as="select"
id="rootFolder"
name="rootFolder"
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
disabled={!isValidated || isTesting}
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="">
{intl.formatMessage(messages.selectRootFolder)}
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(messages.testFirstRootFolders)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
@@ -504,6 +540,92 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="activeAnimeProfileId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.animequalityprofile)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<Field
as="select"
id="activeAnimeProfileId"
name="activeAnimeProfileId"
disabled={!isValidated || isTesting}
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeAnimeProfileId &&
touched.activeAnimeProfileId && (
<div className="text-red-500 mt-2">
{errors.activeAnimeProfileId}
</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="activeAnimeRootFolder"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.animerootfolder)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<Field
as="select"
id="activeAnimeRootFolder"
name="activeAnimeRootFolder"
disabled={!isValidated || isTesting}
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(messages.testFirstRootFolders)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.activeAnimeRootFolder &&
touched.activeAnimeRootFolder && (
<div className="text-red-500 mt-2">
{errors.rootFolder}
</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label
htmlFor="is4k"

View File

@@ -8,6 +8,8 @@ import LoginWithPlex from './LoginWithPlex';
import SetupSteps from './SetupSteps';
import axios from 'axios';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Badge from '../Common/Badge';
import LanguagePicker from '../Layout/LanguagePicker';
const messages = defineMessages({
finish: 'Finish Setup',
@@ -16,6 +18,9 @@ const messages = defineMessages({
loginwithplex: 'Login with Plex',
configureplex: 'Configure Plex',
configureservices: 'Configure Services',
tip: 'Tip',
syncingbackground:
'Syncing will run in the background. You can continue the setup process in the meantime.',
});
const Setup: React.FC = () => {
@@ -47,7 +52,10 @@ const Setup: React.FC = () => {
'/images/rotate4.jpg',
]}
/>
<div className="px-4 sm:px-2 md:px-0 sm:mx-auto sm:w-full sm:max-w-4xl relative z-50">
<div className="absolute top-4 right-4 z-50">
<LanguagePicker />
</div>
<div className="px-4 sm:px-2 md:px-0 sm:mx-auto sm:w-full sm:max-w-4xl relative z-40">
<img
src="/logo.png"
className="mx-auto max-h-32 w-auto mb-10"
@@ -85,6 +93,12 @@ const Setup: React.FC = () => {
{currentStep === 2 && (
<div>
<SettingsPlex onComplete={() => setPlexSettingsComplete(true)} />
<div className="mt-4 text-gray-500 text-sm">
<span className="mr-2">
<Badge>{intl.formatMessage(messages.tip)}</Badge>
</span>
{intl.formatMessage(messages.syncingbackground)}
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">

View File

@@ -10,17 +10,18 @@ import Link from 'next/link';
import { MediaStatus } from '../../../server/constants/media';
import RequestModal from '../RequestModal';
import { defineMessages, useIntl } from 'react-intl';
import { useIsTouch } from '../../hooks/useIsTouch';
const messages = defineMessages({
movie: 'MOVIE',
tvshow: 'SERIES',
movie: 'Movie',
tvshow: 'Series',
});
interface TitleCardProps {
id: number;
image?: string;
summary?: string;
year: string;
year?: string;
title: string;
userScore: number;
mediaType: MediaType;
@@ -38,6 +39,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
mediaType,
canExpand = false,
}) => {
const isTouch = useIsTouch();
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const [currentStatus, setCurrentStatus] = useState(status);
@@ -74,9 +76,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
<div
className="titleCard outline-none cursor-default"
style={{
backgroundImage: `url(//image.tmdb.org/t/p/w600_and_h900_bestv2${image})`,
backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`,
}}
onMouseEnter={() => {
if (!isTouch) {
setShowDetail(true);
}
}}
onMouseEnter={() => setShowDetail(true)}
onMouseLeave={() => setShowDetail(false)}
onClick={() => setShowDetail(true)}
onKeyDown={(e) => {
@@ -146,158 +152,162 @@ const TitleCard: React.FC<TitleCardProps> = ({
<Transition
show={!image || showDetail || showRequestModal}
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition transform opacity-0"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in-out duration-300 transform opacity-100"
leave="transition transform opacity-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="absolute w-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden"
style={{
background:
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
}}
>
<div className="absolute bottom-0 w-full left-0 right-0">
<div className="px-2 text-white">
<div className="text-sm">{year}</div>
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
<a
className="absolute w-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden cursor-pointer"
style={{
background:
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
}}
>
<div className="absolute bottom-0 w-full left-0 right-0">
<div className="px-2 text-white">
{year && <div className="text-sm">{year}</div>}
<h1 className="text-xl leading-tight whitespace-normal">
{title}
</h1>
<div
className="text-xs whitespace-normal"
style={{
WebkitLineClamp: 3,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
}}
>
{summary}
<h1 className="text-xl leading-tight whitespace-normal">
{title}
</h1>
<div
className="text-xs whitespace-normal"
style={{
WebkitLineClamp: 3,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
}}
>
{summary}
</div>
</div>
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
<Link
href={
mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`
}
>
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150">
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</a>
</Link>
{(!currentStatus ||
currentStatus === MediaStatus.UNKNOWN) && (
<button
onClick={(e) => {
e.preventDefault();
setShowRequestModal(true);
}}
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150"
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PENDING && (
<button
className="w-full h-7 text-center text-yellow-500 border border-yellow-500 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PROCESSING && (
<button
className="w-full h-7 text-center text-red-500 border border-red-500 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
)}
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<button
className="w-full h-7 text-center text-green-400 border border-green-400 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</button>
)}
</div>
</div>
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
<Link
href={
mediaType === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'
}
as={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}
>
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150">
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</a>
</Link>
{(!currentStatus ||
currentStatus === MediaStatus.UNKNOWN) && (
<button
onClick={() => setShowRequestModal(true)}
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150"
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PENDING && (
<button
className="w-full h-7 text-center text-yellow-500 border border-yellow-500 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PROCESSING && (
<button
className="w-full h-7 text-center text-red-500 border border-red-500 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
)}
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<button
className="w-full h-7 text-center text-green-400 border border-green-400 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</button>
)}
</div>
</div>
</div>
</a>
</Link>
</Transition>
</div>
</div>

View File

@@ -0,0 +1,66 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useContext } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { TvDetails } from '../../../../server/models/Tv';
import { LanguageContext } from '../../../context/LanguageContext';
import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
const messages = defineMessages({
fullseriescrew: 'Full Series Crew',
});
const TvCrew: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { data, error } = useSWR<TvDetails>(
`/api/v1/tv/${router.query.tvId}?language=${locale}`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
return (
<>
<Header
subtext={
<Link href={`/tv/${data.id}`}>
<a className="hover:underline">{data.name}</a>
</Link>
}
>
{intl.formatMessage(messages.fullseriescrew)}
</Header>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{data?.credits.crew.map((person, index) => {
return (
<li
key={`crew-${person.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<PersonCard
name={person.name}
personId={person.id}
subName={person.job}
profilePath={person.profilePath}
canExpand
/>
</li>
);
})}
</ul>
</>
);
};
export default TvCrew;

View File

@@ -1,5 +1,10 @@
import React, { useState, useContext } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import React, { useState, useContext, useMemo } from 'react';
import {
FormattedMessage,
FormattedDate,
defineMessages,
useIntl,
} from 'react-intl';
import useSWR from 'swr';
import { useRouter } from 'next/router';
import Button from '../Common/Button';
@@ -28,8 +33,13 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import type { RTRating } from '../../../server/api/rottentomatoes';
import Head from 'next/head';
import globalMessages from '../../i18n/globalMessages';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb';
import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
import { Crew } from '../../../server/models/common';
const messages = defineMessages({
firstAirDate: 'First Air Date',
userrating: 'User Rating',
status: 'Status',
originallanguage: 'Original Language',
@@ -56,6 +66,10 @@ const messages = defineMessages({
'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.',
approve: 'Approve',
decline: 'Decline',
showtype: 'Show Type',
anime: 'Anime',
network: 'Network',
viewfullcrew: 'View Full Crew',
});
interface TvDetailsProps {
@@ -100,6 +114,10 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
`/api/v1/tv/${router.query.tvId}/ratings`
);
const sortedCrew = useMemo(() => sortCrewPriority(data?.credits.crew ?? []), [
data,
]);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -133,9 +151,17 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
}
};
const isComplete =
data.seasons.filter((season) => season.seasonNumber !== 0).length <=
(
data.mediaInfo?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
return (
<div
className="bg-cover bg-center -mx-4 -mt-2 px-4 sm:px-8 pt-4 "
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 "
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
@@ -160,21 +186,21 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
onClose={() => setShowManager(false)}
subText={data.name}
>
<h3 className="text-xl mb-2">
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="bg-gray-600 shadow overflow-hidden rounded-md">
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.requests?.map((request) => (
<li
key={`manage-request-${request.id}`}
className="border-b last:border-b-0 border-gray-700"
className="border-b border-gray-700 last:border-b-0"
>
<RequestBlock request={request} onUpdate={() => revalidate()} />
</li>
))}
{(data.mediaInfo?.requests ?? []).length === 0 && (
<li className="text-center py-4 text-gray-400">
<li className="py-4 text-center text-gray-400">
{intl.formatMessage(messages.manageModalNoRequests)}
</li>
)}
@@ -189,21 +215,21 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
>
{intl.formatMessage(messages.manageModalClearMedia)}
</Button>
<div className="text-sm text-gray-400 mt-2">
<div className="mt-2 text-sm text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning)}
</div>
</div>
)}
</SlideOver>
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
<div className="mr-4 flex-shrink-0">
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
<div className="flex-shrink-0 md:mr-4">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-52"
/>
</div>
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
<div className="flex flex-col mt-4 text-center text-white md:mr-4 md:mt-0 md:text-left">
<div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
@@ -227,14 +253,18 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)}
</div>
<h1 className="text-2xl md:text-4xl">
{data.name}{' '}
<span className="text-2xl">({data.firstAirDate.slice(0, 4)})</span>
<span>{data.name}</span>
{data.firstAirDate && (
<span className="ml-2 text-2xl">
({data.firstAirDate.slice(0, 4)})
</span>
)}
</h1>
<span className="text-xs md:text-base mt-1 md:mt-0">
<span className="mt-1 text-xs md:text-base md:mt-0">
{data.genres.map((g) => g.name).join(', ')}
</span>
</div>
<div className="flex-1 flex justify-end mt-4 md:mt-0">
<div className="flex justify-end flex-1 mt-4 md:mt-0">
{(!data.mediaInfo ||
data.mediaInfo.status === MediaStatus.UNKNOWN) && (
<Button
@@ -258,93 +288,95 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<FormattedMessage {...messages.request} />
</Button>
)}
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
<ButtonWithDropdown
dropdownIcon={
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clipRule="evenodd"
/>
</svg>
}
text={
<>
{data.mediaInfo &&
data.mediaInfo.status !== MediaStatus.UNKNOWN &&
!isComplete && (
<ButtonWithDropdown
dropdownIcon={
<svg
className="w-4 mr-1"
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage {...messages.requestmore} />
</>
}
onClick={() => setShowRequestModal(true)}
>
{hasPermission(Permission.MANAGE_REQUESTS) &&
activeRequests &&
activeRequests.length > 0 && (
}
text={
<>
<ButtonWithDropdown.Item
onClick={() => modifyRequests('approve')}
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage
{...messages.approverequests}
values={{ requestCount: activeRequests.length }}
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</ButtonWithDropdown.Item>
<ButtonWithDropdown.Item
onClick={() => modifyRequests('decline')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage
{...messages.declinerequests}
values={{ requestCount: activeRequests.length }}
/>
</ButtonWithDropdown.Item>
</svg>
<FormattedMessage {...messages.requestmore} />
</>
)}
</ButtonWithDropdown>
)}
}
onClick={() => setShowRequestModal(true)}
>
{hasPermission(Permission.MANAGE_REQUESTS) &&
activeRequests &&
activeRequests.length > 0 && (
<>
<ButtonWithDropdown.Item
onClick={() => modifyRequests('approve')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage
{...messages.approverequests}
values={{ requestCount: activeRequests.length }}
/>
</ButtonWithDropdown.Item>
<ButtonWithDropdown.Item
onClick={() => modifyRequests('decline')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage
{...messages.declinerequests}
values={{ requestCount: activeRequests.length }}
/>
</ButtonWithDropdown.Item>
</>
)}
</ButtonWithDropdown>
)}
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"
className="ml-2"
className="ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<svg
@@ -372,7 +404,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)}
</div>
</div>
<div className="flex pt-8 text-white flex-col md:flex-row pb-4">
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">
<FormattedMessage {...messages.overview} />
@@ -382,74 +414,181 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
<ul className="grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3">
{(data.createdBy.length > 0
? [
...data.createdBy.map(
(person): Partial<Crew> => ({
id: person.id,
job: 'Creator',
name: person.name,
})
),
...sortedCrew,
]
: sortedCrew
)
.slice(0, 6)
.map((person) => (
<li
className="flex flex-col col-span-1"
key={`crew-${person.job}-${person.id}`}
>
<span className="font-bold">{person.job}</span>
<Link href={`/person/${person.id}`}>
<a className="text-gray-400 transition duration-300 hover:text-underline hover:text-gray-100">
{person.name}
</a>
</Link>
</li>
))}
</ul>
{sortedCrew.length > 0 && (
<div className="flex justify-end mt-4">
<Link href={`/tv/${data.id}/crew`}>
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
<svg
className="inline-block w-5 h-5 ml-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
)}
</div>
<div className="w-full md:w-80 mt-8 md:mt-0">
<div className="bg-gray-900 rounded-lg shadow border border-gray-800">
<div className="w-full mt-8 md:w-80 md:mt-0">
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
{(data.voteCount > 0 || ratingData) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0 items-center justify-center">
{ratingData?.criticsRating && (
<>
<span className="text-sm">
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="w-6 mr-1" />
) : (
<RTFresh className="w-6 mr-1" />
)}
</span>
<span className="text-gray-400 text-sm mr-4 last:mr-0">
{ratingData.criticsScore}%
</span>
</>
)}
{ratingData?.audienceRating && (
<>
<span className="text-sm">
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6 mr-1" />
) : (
<RTAudFresh className="w-6 mr-1" />
)}
</span>
<span className="text-gray-400 text-sm mr-4 last:mr-0">
{ratingData.audienceScore}%
</span>
</>
)}
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
{ratingData?.criticsRating &&
(ratingData?.criticsScore ?? 0) > 0 && (
<>
<span className="text-sm">
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="w-6 mr-1" />
) : (
<RTFresh className="w-6 mr-1" />
)}
</span>
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.criticsScore}%
</span>
</>
)}
{ratingData?.audienceRating &&
(ratingData?.audienceScore ?? 0) > 0 && (
<>
<span className="text-sm">
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6 mr-1" />
) : (
<RTAudFresh className="w-6 mr-1" />
)}
</span>
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.audienceScore}%
</span>
</>
)}
{data.voteCount > 0 && (
<>
<span className="text-sm">
<TmdbLogo className="w-6 mr-2" />
</span>
<span className="text-gray-400 text-sm">
<span className="text-sm text-gray-400">
{data.voteAverage}/10
</span>
</>
)}
</div>
)}
{data.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.showtype)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
{intl.formatMessage(messages.anime)}
</span>
</div>
)}
{data.firstAirDate && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.firstAirDate} />
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedDate
value={new Date(data.firstAirDate)}
year="numeric"
month="long"
day="numeric"
/>
</span>
</div>
)}
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.status} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
{data.status}
</span>
</div>
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.originallanguage} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
{data.originalLanguage}
</span>
</div>
{data.spokenLanguages.some(
(lng) => lng.iso_639_1 === data.originalLanguage
) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.originallanguage} />
</span>
<span className="flex-1 text-sm text-right text-gray-400">
{
data.spokenLanguages.find(
(lng) => lng.iso_639_1 === data.originalLanguage
)?.name
}
</span>
</div>
)}
{data.networks.length > 0 && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.network} />
</span>
<span className="flex-1 text-sm text-right text-gray-400">
{data.networks.map((n) => n.name).join(', ')}
</span>
</div>
)}
</div>
<div className="mt-4">
<ExternalLinkBlock
mediaType="tv"
tmdbId={data.id}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
/>
</div>
</div>
</div>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link href="/tv/[tvId]/cast" as={`/tv/${data.id}/cast`}>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.cast} />
</span>
@@ -487,13 +626,13 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
/>
{(recommended?.results ?? []).length > 0 && (
<>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link
href="/tv/[tvId]/recommendations"
as={`/tv/${data.id}/recommendations`}
>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.recommendations} />
</span>
@@ -537,10 +676,10 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)}
{(similar?.results ?? []).length > 0 && (
<>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link href="/tv/[tvId]/similar" as={`/tv/${data.id}/similar`}>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.similar} />
</span>

View File

@@ -9,7 +9,7 @@ import axios from 'axios';
import { useToasts } from 'react-toast-notifications';
import Header from '../Common/Header';
const messages = defineMessages({
export const messages = defineMessages({
edituser: 'Edit User',
username: 'Username',
avatar: 'Avatar',
@@ -23,13 +23,12 @@ const messages = defineMessages({
'Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.',
settings: 'Manage Settings',
settingsDescription:
'Grants permission to modify all Overseerr settings. User must have this permission to be able to grant it to others.',
'Grants permission to modify all Overseerr settings. A user must have this permission to grant it to others.',
managerequests: 'Manage Requests',
managerequestsDescription:
'Grants permission to manage Overseerr requests. This includes approving and denying requests.',
request: 'Request',
requestDescription:
'Grants permission to make requests for movies or tv shows.',
requestDescription: 'Grants permission to request movies and series.',
vote: 'Vote',
voteDescription:
'Grants permission to vote on requests (voting not yet implemented)',
@@ -38,7 +37,7 @@ const messages = defineMessages({
'Grants auto approval for any requests made by this user.',
save: 'Save',
saving: 'Saving...',
usersaved: 'User succesfully saved',
usersaved: 'User saved',
userfail: 'Something went wrong saving the user.',
});
@@ -149,7 +148,7 @@ const UserEdit: React.FC = () => {
<FormattedMessage {...messages.edituser} />
</Header>
<div className="px-4 space-y-6 sm:p-6 lg:pb-8">
<div className="flex flex-col space-y-6 lg:flex-row lg:space-y-0 lg:space-x-6 text-white">
<div className="flex flex-col space-y-6 text-white lg:flex-row lg:space-y-0 lg:space-x-6">
<div className="flex-grow space-y-6">
<div className="space-y-1">
<label
@@ -158,11 +157,11 @@ const UserEdit: React.FC = () => {
>
<FormattedMessage {...messages.username} />
</label>
<div className="rounded-md shadow-sm flex">
<div className="flex rounded-md shadow-sm">
<input
id="username"
type="text"
className="form-input flex-grow block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-grow block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
value={user?.username}
readOnly
/>
@@ -175,11 +174,11 @@ const UserEdit: React.FC = () => {
>
<FormattedMessage {...messages.email} />
</label>
<div className="rounded-md shadow-sm flex">
<div className="flex rounded-md shadow-sm">
<input
id="email"
type="text"
className="form-input flex-grow block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-grow block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
value={user?.email}
readOnly
/>
@@ -189,7 +188,7 @@ const UserEdit: React.FC = () => {
<div className="flex-grow space-y-1 lg:flex-grow-0 lg:flex-shrink-0">
<p
className="block text-sm leading-5 font-medium text-gray-400"
className="block text-sm font-medium leading-5 text-gray-400"
aria-hidden="true"
>
<FormattedMessage {...messages.avatar} />
@@ -197,11 +196,11 @@ const UserEdit: React.FC = () => {
<div className="lg:hidden">
<div className="flex items-center">
<div
className="flex-shrink-0 inline-block rounded-full overflow-hidden h-12 w-12"
className="flex-shrink-0 inline-block w-12 h-12 overflow-hidden rounded-full"
aria-hidden="true"
>
<img
className="rounded-full h-full w-full"
className="w-full h-full rounded-full"
src={user?.avatar}
alt=""
/>
@@ -209,9 +208,9 @@ const UserEdit: React.FC = () => {
</div>
</div>
<div className="hidden relative rounded-full overflow-hidden lg:block transition duration-150 ease-in-out">
<div className="relative hidden overflow-hidden transition duration-150 ease-in-out rounded-full lg:block">
<img
className="relative rounded-full w-40 h-40"
className="relative w-40 h-40 rounded-full"
src={user?.avatar}
alt=""
/>
@@ -224,7 +223,7 @@ const UserEdit: React.FC = () => {
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base leading-6 font-medium sm:text-sm sm:leading-5"
className="text-base font-medium leading-6 sm:text-sm sm:leading-5"
id="label-permissions"
>
<FormattedMessage {...messages.permissions} />
@@ -255,7 +254,7 @@ const UserEdit: React.FC = () => {
id={permissionOption.id}
name="permissions"
type="checkbox"
className="form-checkbox h-4 w-4 rounded-md text-indigo-600 transition duration-150 ease-in-out"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
(permissionOption.permission !==
Permission.ADMIN &&
@@ -306,9 +305,9 @@ const UserEdit: React.FC = () => {
</div>
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import useSWR from 'swr';
import LoadingSpinner from '../Common/LoadingSpinner';
import type { User } from '../../../server/entity/User';
@@ -10,9 +10,18 @@ import { Permission } from '../../hooks/useUser';
import { useRouter } from 'next/router';
import Header from '../Common/Header';
import Table from '../Common/Table';
import Transition from '../Transition';
import Modal from '../Common/Modal';
import axios from 'axios';
import { useToasts } from 'react-toast-notifications';
import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({
userlist: 'User List',
importfromplex: 'Import Users From Plex',
importfromplexerror: 'Something went wrong importing users from Plex',
importedfromplex:
'{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex',
username: 'Username',
totalrequests: 'Total Requests',
usertype: 'User Type',
@@ -24,12 +33,75 @@ const messages = defineMessages({
admin: 'Admin',
user: 'User',
plexuser: 'Plex User',
deleteuser: 'Delete User',
userdeleted: 'User deleted',
userdeleteerror: 'Something went wrong deleting the user',
deleteconfirm:
'Are you sure you want to delete this user? All existing request data from this user will be removed.',
});
const UserList: React.FC = () => {
const intl = useIntl();
const router = useRouter();
const { data, error } = useSWR<User[]>('/api/v1/user');
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR<User[]>('/api/v1/user');
const [isDeleting, setDeleting] = useState(false);
const [isImporting, setImporting] = useState(false);
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean;
user?: User;
}>({
isOpen: false,
});
const deleteUser = async () => {
setDeleting(true);
try {
await axios.delete(`/api/v1/user/${deleteModal.user?.id}`);
addToast(intl.formatMessage(messages.userdeleted), {
autoDismiss: true,
appearance: 'success',
});
setDeleteModal({ isOpen: false });
} catch (e) {
addToast(intl.formatMessage(messages.userdeleteerror), {
autoDismiss: true,
appearance: 'error',
});
} finally {
setDeleting(false);
revalidate();
}
};
const importFromPlex = async () => {
setImporting(true);
try {
const { data: createdUsers } = await axios.post(
'/api/v1/user/import-from-plex'
);
addToast(
intl.formatMessage(messages.importedfromplex, {
userCount: createdUsers.length,
}),
{
autoDismiss: true,
appearance: 'success',
}
);
} catch (e) {
addToast(intl.formatMessage(messages.importfromplexerror), {
autoDismiss: true,
appearance: 'error',
});
} finally {
revalidate();
setImporting(false);
}
};
if (!data && !error) {
return <LoadingSpinner />;
@@ -37,7 +109,57 @@ const UserList: React.FC = () => {
return (
<>
<Header extraMargin={4}>{intl.formatMessage(messages.userlist)}</Header>
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={deleteModal.isOpen}
>
<Modal
onOk={() => deleteUser()}
okText={
isDeleting
? intl.formatMessage(globalMessages.deleting)
: intl.formatMessage(globalMessages.delete)
}
okDisabled={isDeleting}
okButtonType="danger"
onCancel={() => setDeleteModal({ isOpen: false })}
title={intl.formatMessage(messages.deleteuser)}
iconSvg={
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
}
>
{intl.formatMessage(messages.deleteconfirm)}
</Modal>
</Transition>
<div className="flex items-center justify-between">
<Header extraMargin={4}>{intl.formatMessage(messages.userlist)}</Header>
<Button
className="mx-4 my-8"
buttonType="primary"
disabled={isImporting}
onClick={() => importFromPlex()}
>
{intl.formatMessage(messages.importfromplex)}
</Button>
</div>
<Table>
<thead>
<tr>
@@ -55,18 +177,18 @@ const UserList: React.FC = () => {
<tr key={`user-list-${user.id}`}>
<Table.TD>
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="flex-shrink-0 w-10 h-10">
<img
className="h-10 w-10 rounded-full"
className="w-10 h-10 rounded-full"
src={user.avatar}
alt=""
/>
</div>
<div className="ml-4">
<div className="text-sm leading-5 font-medium">
<div className="text-sm font-medium leading-5">
{user.username}
</div>
<div className="text-sm leading-5 text-gray-300">
<div className="text-sm text-gray-300 leading-5">
{user.email}
</div>
</div>
@@ -104,7 +226,11 @@ const UserList: React.FC = () => {
>
{intl.formatMessage(messages.edit)}
</Button>
<Button buttonType="danger">
<Button
buttonType="danger"
disabled={hasPermission(Permission.ADMIN, user.permissions)}
onClick={() => setDeleteModal({ isOpen: true, user })}
>
{intl.formatMessage(messages.delete)}
</Button>
</Table.TD>

View File

@@ -0,0 +1,20 @@
import React from 'react';
import useInteraction from '../hooks/useInteraction';
interface InteractionContextProps {
isTouch: boolean;
}
export const InteractionContext = React.createContext<InteractionContextProps>({
isTouch: false,
});
export const InteractionProvider: React.FC = ({ children }) => {
const isTouch = useInteraction();
return (
<InteractionContext.Provider value={{ isTouch }}>
{children}
</InteractionContext.Provider>
);
};

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