Compare commits

..

175 Commits

Author SHA1 Message Date
sct
238f39ace6 fix: update buildx package 2021-03-12 10:43:32 +00:00
sct
44be243516 build: update release publish step 2021-03-12 10:27:27 +00:00
sct
47ce7d48ba Merge branch 'master' into arm-test 2021-03-12 10:26:38 +00:00
sct
bb9dc29bad fix: remove some build steps for testing and update package 2021-03-12 10:22:29 +00:00
sct
bffb84d08b fix: try custom buildx semantic-release package 2021-03-12 09:45:25 +00:00
sct
a66872588e ci: load resulting build image into docker 2021-03-12 09:34:42 +00:00
sct
a10921e411 ci: temporarily remove test/snap build for testing 2021-03-12 09:34:42 +00:00
sct
7ca08057a7 ci: add arm-test for release 2021-03-12 09:34:42 +00:00
sct
8634081c86 fix: correctly send auto-approval notifictions for series
also adjusted see more card slightly
2021-03-12 07:29:32 +00:00
TheCatLady
f7d2723fab feat(notif): add "Media Automatically Approved" notification type (#1137) 2021-03-12 02:42:06 +00:00
TheCatLady
1be97fe7fb refactor(css): add new global classes & card border hover styling (#1156) 2021-03-12 11:25:46 +09:00
TheCatLady
71773c91c6 docs: edit contribution guide (#1158) [skip ci] 2021-03-12 09:50:52 +09:00
sct
6e95c8b7a1 feat(ui): add user ID to profile header
also adds some missing localized strings for the profile header
2021-03-11 14:57:25 +00:00
TheCatLady
b36aff912a refactor(ui): move user-related settings out of "General Settings" (#1143) 2021-03-11 14:55:11 +00:00
Weblate (bot)
e4686d664b feat(lang): Translations update from Weblate (#1131)
* feat(lang): translated using Weblate (German)

Currently translated at 100.0% (795 of 795 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: HubDuck <github@moll.one>
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% (797 of 797 strings)

Co-authored-by: Mathieu VERMEEREN <mathieu.vermeeren@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (795 of 795 strings)

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

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

Currently translated at 100.0% (797 of 797 strings)

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

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

Currently translated at 100.0% (797 of 797 strings)

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

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

Currently translated at 100.0% (797 of 797 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (797 of 797 strings)

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

Currently translated at 47.7% (380 of 795 strings)

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

Co-authored-by: HubDuck <github@moll.one>
Co-authored-by: Mathieu VERMEEREN <mathieu.vermeeren@gmail.com>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Co-authored-by: Artem <KovalevArtem.ru@gmail.com>
2021-03-11 09:22:35 -05:00
sct
18a7876668 build(deps): bump dependencies 2021-03-11 05:06:47 +00:00
sct
d48edeb5a9 fix(frontend): never hide available content in search results 2021-03-11 05:01:52 +00:00
sct
a51d2a24d5 feat(scan): add support for new plex tv agent (#1144) 2021-03-11 12:19:11 +09:00
TheCatLady
fc73592b69 fix(ui): request list button sizes (#1152) 2021-03-11 12:08:58 +09:00
TheCatLady
60d453b0bb fix: fix language filter link on movie detail pages (#1142) 2021-03-10 03:04:57 +00:00
TheCatLady
45886ccef1 fix(ui): display "Season" vs. "Seasons" as appropriate, and fix request block "Seasons" formatting (#1127) 2021-03-10 11:39:42 +09:00
sct
84ef40ea8e build(deps): bump dependencies 2021-03-10 02:13:46 +00:00
allcontributors[bot]
512809f7c0 docs: add costaht as a contributor (#1141) [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>
2021-03-10 10:46:05 +09:00
allcontributors[bot]
b85cd5376f docs: add HubDuck as a contributor (#1140) [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>
2021-03-10 10:36:55 +09:00
allcontributors[bot]
1fcd18f0b0 docs: add WoisWoi as a contributor (#1139) [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>
2021-03-10 10:35:34 +09:00
cron410
dbea4f0194 docs: add docker name argument to run command example (#1069) [skip ci] 2021-03-10 01:28:10 +00:00
TheCatLady
75011610e5 feat: add language-filtered Discover pages (#1111) 2021-03-10 10:23:37 +09:00
TheCatLady
109aca8229 fix: remove language/region filtering on studio/network results (#1129) 2021-03-09 23:46:38 +00:00
sct
3c1a72b038 fix(api): check correct permissions for auto approve when requests are created 2021-03-09 15:04:24 +00:00
TheCatLady
3384eb1c47 feat(notif): add settings for Discord bot username & avatar URL (#1113) 2021-03-09 12:42:32 +00:00
sct
e5f5bdb95c fix(frontend): check if swr is validating to determine if we should fetch new data
fixes #719
2021-03-09 10:22:33 +00:00
sct
36e614f4f3 refactor(ui): adjust language picker style 2021-03-09 04:39:05 +00:00
sct
f5fc0545a4 refactor(ui): add borders to request cards 2021-03-09 04:29:51 +00:00
Weblate (bot)
69bf817f59 feat(lang): translations update from Weblate (#1051)
* feat(lang): translated using Weblate (German)

Currently translated at 100.0% (789 of 789 strings)

feat(lang): translated using Weblate (German)

Currently translated at 99.0% (769 of 776 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (769 of 769 strings)

feat(lang): translated using Weblate (German)

Currently translated at 99.8% (768 of 769 strings)

Co-authored-by: Andreas Fenz <fenz.andreas@hotmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Paul Hagedorn <panzer1119@gmail.com>
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% (789 of 789 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.2% (783 of 789 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (782 of 782 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.7% (779 of 781 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.4% (772 of 776 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (769 of 769 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Mathieu VERMEEREN <mathieu.vermeeren@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (789 of 789 strings)

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

Currently translated at 100.0% (769 of 769 strings)

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

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

Currently translated at 100.0% (789 of 789 strings)

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

Currently translated at 99.2% (770 of 776 strings)

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

Currently translated at 100.0% (769 of 769 strings)

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

Currently translated at 100.0% (761 of 761 strings)

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

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

Currently translated at 100.0% (789 of 789 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.4% (785 of 789 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.4% (778 of 782 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.8% (780 of 781 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (769 of 769 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (769 of 769 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (761 of 761 strings)

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

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

Currently translated at 100.0% (789 of 789 strings)

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

Currently translated at 100.0% (789 of 789 strings)

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

Currently translated at 100.0% (789 of 789 strings)

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

Currently translated at 100.0% (782 of 782 strings)

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

Currently translated at 100.0% (781 of 781 strings)

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

Currently translated at 100.0% (781 of 781 strings)

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

Currently translated at 99.3% (776 of 781 strings)

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

Currently translated at 100.0% (776 of 776 strings)

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

Currently translated at 99.8% (775 of 776 strings)

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

Currently translated at 100.0% (769 of 769 strings)

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

Currently translated at 100.0% (769 of 769 strings)

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

Currently translated at 100.0% (763 of 763 strings)

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

Currently translated at 100.0% (761 of 761 strings)

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

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

Currently translated at 100.0% (789 of 789 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 97.8% (772 of 789 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 99.2% (770 of 776 strings)

feat(lang): translated using Weblate (Italian)

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

Co-authored-by: Andreas Fenz <fenz.andreas@hotmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Paul Hagedorn <panzer1119@gmail.com>
Co-authored-by: Mathieu VERMEEREN <mathieu.vermeeren@gmail.com>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: TheCatLady <doris.houng@gmail.com>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
2021-03-09 11:51:33 +09:00
sct
1c6914f5ce feat: add studio/network sliders to discover
includes some adjustments to titlecard design
2021-03-09 02:23:29 +00:00
TheCatLady
57bc340840 ci(docker): don't cache first build stage, and add temp fix for growing cache (#1124)
* ci(docker): don't cache first build stage, and add temp fix for growing cache
2021-03-09 07:56:45 +09:00
TheCatLady
48387e5b2f feat(notif): include poster image in Telegram notifications (#1112) 2021-03-08 19:22:03 +00:00
Brandon Cohen
1a7dc1acf5 fix(frontend): status, requested by, and modified alignment fix (#1109) 2021-03-08 16:00:51 +00:00
allcontributors[bot]
d0fa5f3dbe docs: add Roxedus as a contributor (#1118) [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>
2021-03-08 10:53:25 +09:00
allcontributors[bot]
f900d953a0 docs: add JonnyWong16 as a contributor (#1115) [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>
2021-03-08 10:52:11 +09:00
JonnyWong16
74e39b0b45 docs(proxy): add nginx subfolder reverse proxy (#1114) [skip ci] 2021-03-07 19:41:52 -05:00
TheCatLady
239202d9c1 fix(ui): list all movie studios instead of just the first result (#1110) 2021-03-08 08:06:50 +09:00
TheCatLady
85076919c6 fix(ui): fix request list UI behavior when season list is too long (#1106)
* fix(ui): fix request list UI behavior when season list is too long

* fix: add default variants to Tailwind scale
2021-03-07 10:40:47 +09:00
sct
9d0b52a242 ci: add network-timeout to yarn install 2021-03-06 15:17:04 +00:00
sct
f86d907c74 ci: also add arm/v7 build for develop tag 2021-03-06 11:12:18 +00:00
ᗪєνιη ᗷυнʟ
9966632c64 ci: build arm64 and amd64 container images for ci images (#1036) 2021-03-06 09:50:28 +00:00
sct
7aa0239cd9 refactor(ui): request list now adds a page query parameter for switching pages
this helps return back to the page you were last looking at when navigated to and from the request
list.
2021-03-06 08:15:39 +00:00
sct
7289872937 fix(ui): add link to poster image on request items 2021-03-06 07:49:41 +00:00
TheCatLady
1f8b03ff6f fix(ui): improve responsive design on new request list UI (#1105) 2021-03-06 01:54:31 +00:00
TheCatLady
e98f2b9605 fix(ui): correct language usage re: "sync" vs. "scan" (#1079) 2021-03-06 10:16:56 +09:00
allcontributors[bot]
92508b3f42 docs: add onedr0p as a contributor (#1101) [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>
2021-03-06 01:15:23 +09:00
allcontributors[bot]
4f1a8a0a78 docs: add NuroDev as a contributor (#1100) [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>
2021-03-06 01:05:34 +09:00
nuro
b5ce7f0cab docs: added Docker compose installation example (#1072) [skip ci]
* Added Docker compose installation example

* Update docs/getting-started/installation.md

Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>

* Updated compose example formatting

* Added complete docker-compose file example

* Update docs/getting-started/installation.md

Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>

* Added Docker compose installation example

* Update docs/getting-started/installation.md

Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>

* Updated compose example formatting

* Added complete docker-compose file example

* Update docs/getting-started/installation.md

Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>

Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Co-authored-by: sct <ryan@sct.dev>
2021-03-06 01:04:51 +09:00
TheCatLady
778dda67d5 fix(frontend): check for ID instead of email after initial setup Plex login (#1097) 2021-03-05 15:52:43 +00:00
sct
cd21865c4d feat(ui): request list redesign (#1099) 2021-03-06 00:46:53 +09:00
TheCatLady
0c4637f779 fix(ui): add alt prop to studio/network logos & fix blinking text cursor (#1095) 2021-03-05 17:33:20 +09:00
Jakob Ankarhem
420038d5ff fix(requests): add plex url to request item (#1088) 2021-03-05 07:12:48 +00:00
TheCatLady
bdf67e732b fix(lang): edit new Telegram-related strings to conform to style guide (#1093) 2021-03-05 12:35:08 +09:00
Jakob Ankarhem
f6d00d8d15 feat(telegram): add support for individual chat notifications (#1027) 2021-03-05 09:18:56 +09:00
sct
6072e8aa9a fix(lang): add missing i18n strings 2021-03-04 10:21:34 +00:00
sct
63c122e5e0 refactor: move genre/studio/network calls into their own endpoints
this commit also adds a `useDiscover` hook to help with creating discover pages with less repeating
code
2021-03-04 10:18:46 +00:00
sct
ed0a7fbdf5 fix(sonarr): correctly search when updating existing sonarr series
fixes #588
2021-03-04 05:04:15 +00:00
TheCatLady
b57645d382 fix(lang): fix singular form of season count (#1080) 2021-03-04 04:31:33 +00:00
TheCatLady
f28112f057 feat: add genre/studio/network view to Discover results (#1067)
* feat: add genres view to movie/series Discover results

* feat: add studio/network view to movie/series Discover results

* fix: remove with_release_type filter, since it is removing valid/desired results
2021-03-04 04:22:35 +00:00
TheCatLady
436523139e feat(ui): display season count on TV details page (#1078)
* feat(ui): display season count on TV details page

* feat(lang): add new translation string
2021-03-04 03:42:19 +00:00
TheCatLady
0fa005a99c fix(ui): fix Radarr logo alignment (#1068) 2021-03-04 01:27:19 +00:00
TheCatLady
6a1e3891aa feat(ui): in Settings > Services, make Radarr/Sonarr server names and logos clickable links (#1008) 2021-03-04 00:18:27 +00:00
ᗪєνιη ᗷυнʟ
1629d02f3d feat(docker): add tini to docker image (#1017) 2021-03-03 23:50:53 +00:00
TheCatLady
594aad9d3a feat(lang): localize job names (#1043) 2021-03-04 08:34:21 +09:00
samwiseg0
1d45f427e5 build(snap): bump node to 14.16.0 for snap (#1058) 2021-03-02 14:51:47 +00:00
TheCatLady
253ec15bc4 build(docker): bump node from 14.15 to 14.16 (#1057)
Node v14.x (LTS) update from 2021-02-23 includes vulnerability fixes
https://nodejs.org/en/blog/release/v14.16.0/
2021-03-02 13:11:07 +00:00
TheCatLady
b146d11e2f fix(ui): don't show "Password" user settings tab if current user lacks perms to modify the password (#1063) 2021-03-02 13:04:50 +00:00
sct
bbc0b74b9d build(deps): bump dependencies 2021-03-02 12:53:08 +00:00
TheCatLady
1b55d2dfbc feat(ui): display "Owner" role instead of "Admin" for user ID 1 (#1050)
* feat(ui): display "Owner" role instead of "Admin" for user ID 1

Also add role to user settings page, and fix the missing "Account Type" string and use the same
verbiage on the user list page

* feat(lang): generate translation keys

* fix: utilize hasPermission returned by useUser instead of importing from server/lib/permissions
2021-03-02 19:06:52 +09:00
sct
ddfc5e6aa8 fix: add correct permission checks to modifying user password/permissions 2021-03-02 08:18:31 +00:00
semantic-release-bot
0e612f01f1 chore(release): 1.20.1 2021-02-28 04:10:48 +00:00
sct
cd9d9ae8d2 Merge branch 'develop' 2021-02-28 04:03:43 +00:00
Weblate (bot)
2771376ecc Translations update from Weblate (#1009)
* feat(lang): translated using Weblate (German)

Currently translated at 100.0% (761 of 761 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Paul Hagedorn <panzer1119@gmail.com>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
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% (761 of 761 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Mathieu VERMEEREN <mathieu.vermeeren@gmail.com>
Co-authored-by: NGVICIOUS <giyomu1@gmail.com>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (761 of 761 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_PT/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 32.4% (246 of 759 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marcos <silvamarcos1919@outlook.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 100.0% (761 of 761 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (759 of 759 strings)

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

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

Currently translated at 99.4% (757 of 761 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 99.2% (755 of 761 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>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 39.3% (299 of 759 strings)

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

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

Currently translated at 53.8% (409 of 759 strings)

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

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Paul Hagedorn <panzer1119@gmail.com>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Co-authored-by: Mathieu VERMEEREN <mathieu.vermeeren@gmail.com>
Co-authored-by: NGVICIOUS <giyomu1@gmail.com>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
2021-02-27 23:00:43 -05:00
Danshil Mungur
eefcbcd3dd fix(ui): show translated string on sonarr sucesss/failure toast messages (#1035) 2021-02-28 03:42:01 +00:00
TheCatLady
3fed26cfbe fix(ui): for server default options, display "All" region/language option instead of empty string (#1042) 2021-02-28 03:34:56 +00:00
sct
3f9a116b17 fix(plex-sync): no longer incorrectly sets 4k availability when there isnt any
fixes #990
2021-02-28 03:18:22 +00:00
TheCatLady
aeac15c63c chore(github): lock closed support issues (#1045) [skip ci] 2021-02-28 11:22:45 +09:00
TheCatLady
abcd7c9975 fix(ui): use country-flag-icons instead of country-flag-emoji for RegionSelector (#1011) 2021-02-28 10:42:09 +09:00
sct
9ce88abcc8 fix: added missing language default for ssr context defaults 2021-02-27 12:46:30 +00:00
sct
084917f02d fix: add missing default value for settings context 2021-02-27 12:41:52 +00:00
sct
69294a7c4c fix: allow users to override language/region settings
fixes #1013
2021-02-27 12:37:18 +00:00
sct
537850f414 fix(notifications): correctly send tv auto approval notifications
fixes #1041
2021-02-27 10:00:58 +00:00
Danshil Mungur
e9f2fe910d fix(notif): fixed typo in pushover hint (#1029) 2021-02-26 14:20:29 +09:00
TheCatLady
5560abf459 fix(notif): escape application title in Telegram notifications (#1012) 2021-02-25 13:14:32 +09:00
semantic-release-bot
ea2765f8c7 chore(release): 1.20.0 2021-02-23 12:33:56 +00:00
sct
448e7c3a7d Merge branch 'develop' 2021-02-23 12:26:58 +00:00
TheCatLady
09b5019e95 refactor(api): rename Plex auth endpoint (#949) 2021-02-23 21:26:21 +09:00
sct
6ab3cd77a7 Merge branch 'develop' 2021-02-23 12:09:08 +00:00
Weblate (bot)
970da664b2 feat(lang): translated using Weblate (French) (#1007)
Currently translated at 100.0% (759 of 759 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (759 of 759 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mathieu VERMEEREN <mathieu.vermeeren@gmail.com>
Co-authored-by: TheCatLady <doris.houng@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: Mathieu VERMEEREN <mathieu.vermeeren@gmail.com>
Co-authored-by: TheCatLady <doris.houng@gmail.com>
2021-02-23 12:07:01 +00:00
sct
552a7e30da fix(sonarr): use qualityProfileId instad of profileId when adding series 2021-02-23 10:17:12 +00:00
Weblate (bot)
4296765ad6 feat(lang): translations update from Weblate (#986)
* feat(lang): translated using Weblate (German)

Currently translated at 100.0% (759 of 759 strings)

Co-authored-by: Ben Wallner <ben.david.wallner@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: HubDuck <github@moll.one>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Paul Hagedorn <panzer1119@gmail.com>
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% (759 of 759 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: HubDuck <github@moll.one>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Mathieu VERMEEREN <mathieu.vermeeren@gmail.com>
Co-authored-by: NGVICIOUS <giyomu1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (759 of 759 strings)

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

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

Currently translated at 99.0% (750 of 757 strings)

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

* feat(lang): translated using Weblate (Dutch)
Currently translated at 100.0% (759 of 759 strings)

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

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

Currently translated at 99.3% (754 of 759 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 96.8% (733 of 757 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 (Japanese)

Currently translated at 55.4% (420 of 757 strings)

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

Co-authored-by: Ben Wallner <ben.david.wallner@gmail.com>
Co-authored-by: HubDuck <github@moll.one>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Paul Hagedorn <panzer1119@gmail.com>
Co-authored-by: Mathieu VERMEEREN <mathieu.vermeeren@gmail.com>
Co-authored-by: NGVICIOUS <giyomu1@gmail.com>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Co-authored-by: T'ai <chivalrousjosh@gmail.com>
2021-02-23 04:36:52 -05:00
TheCatLady
6ecd202607 fix(frontend): use region settings instead of hardcoded 'US' value for movie/TV ratings (#1006) 2021-02-23 18:06:23 +09:00
sct
da5ca02f81 fix(services): update all radarr/sonarr endpoints to use v3 2021-02-23 07:27:56 +00:00
TheCatLady
0acad8e9fa fix(ui): add missing string for default Discover Language & edit string for default Discover Region (#1004) 2021-02-23 05:56:22 +00:00
samwiseg0
84b950edc0 build(snap): Revert QEMU version to fix arm64 builds (#1005)
* build(snap): Revert QEMU version to fix arm64 builds

* build(snap): Re-enable arm64 builds
2021-02-23 14:32:41 +09:00
flying-sausages
45cb977eb2 Explain Swizzin installer is not live yet and why (#992) 2021-02-22 08:32:58 -05:00
sct
c4af4c42ab fix(frontend): check for id instead of email after logging in 2021-02-22 12:50:19 +00:00
sct
f9c83e14e5 fix(ui): add fallback for region display name 2021-02-22 12:13:56 +00:00
sct
29757db3a9 build(deps): bump dependencies 2021-02-22 10:12:53 +00:00
TheCatLady
c88fcb2e2d fix(ui): Notification-related string/UI edits and field validation (#985) 2021-02-22 19:09:12 +09:00
allcontributors[bot]
8589eabfff docs: add dancarter as a contributor (#989) [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>
2021-02-22 16:39:59 +09:00
Daniel Carter
b557c06b0a feat(regions): add region/original language setting for filtering Discover (#732) (#942) 2021-02-22 16:39:25 +09:00
TheCatLady
8701fb20d0 fix(ui): Fix settings navigation horizontal scroll issues (#987) 2021-02-22 16:30:44 +09:00
TheCatLady
40b07c35d4 fix(ui): Display 4K download status on 4K status badge (#988) 2021-02-22 09:28:24 +09:00
TheCatLady
b05bbb8db5 chore(github): Edit templates for consistency (#984)
* chore(github): Edit templates for consistency

* Address review comment for PR template

* Fix stale label
2021-02-22 00:49:22 +09:00
TheCatLady
34618edc13 docs: Various edits, and addition of UI style guide (#982) [skip ci] 2021-02-21 09:38:48 +09:00
Weblate (bot)
5acd91bc8e Translations update from Weblate (#912)
* feat(lang): translated using Weblate (French)

Currently translated at 93.8% (698 of 744 strings)

feat(lang): translated using Weblate (French)

Currently translated at 94.5% (697 of 737 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (681 of 681 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.8% (680 of 681 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.7% (668 of 670 strings)

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

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

Currently translated at 100.0% (742 of 742 strings)

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

Currently translated at 99.8% (743 of 744 strings)

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

Currently translated at 98.7% (728 of 737 strings)

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

Currently translated at 100.0% (681 of 681 strings)

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

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

Currently translated at 100.0% (745 of 745 strings)

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

Currently translated at 100.0% (744 of 744 strings)

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

Currently translated at 99.8% (743 of 744 strings)

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

Currently translated at 100.0% (744 of 744 strings)

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

Currently translated at 99.8% (736 of 737 strings)

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

Currently translated at 99.8% (736 of 737 strings)

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

Currently translated at 100.0% (737 of 737 strings)

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

Currently translated at 100.0% (681 of 681 strings)

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

Currently translated at 98.3% (670 of 681 strings)

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

Currently translated at 98.0% (668 of 681 strings)

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

Currently translated at 97.9% (667 of 681 strings)

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

Currently translated at 100.0% (673 of 673 strings)

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

Currently translated at 100.0% (670 of 670 strings)

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

Currently translated at 100.0% (668 of 668 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (742 of 742 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.8% (743 of 744 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (744 of 744 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.8% (736 of 737 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (737 of 737 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (681 of 681 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (673 of 673 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (670 of 670 strings)

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

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

Currently translated at 97.4% (726 of 745 strings)

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

Currently translated at 97.0% (722 of 744 strings)

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

Currently translated at 97.0% (722 of 744 strings)

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

Currently translated at 96.5% (718 of 744 strings)

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

Currently translated at 96.2% (716 of 744 strings)

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

Currently translated at 95.9% (712 of 742 strings)

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

Currently translated at 95.1% (701 of 737 strings)

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

Currently translated at 95.1% (701 of 737 strings)

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

Currently translated at 96.1% (655 of 681 strings)

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

Currently translated at 95.8% (653 of 681 strings)

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

Currently translated at 96.4% (646 of 670 strings)

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

Currently translated at 96.4% (644 of 668 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (745 of 745 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 95.0% (707 of 744 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 90.5% (667 of 737 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 100.0% (681 of 681 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 100.0% (681 of 681 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 98.5% (671 of 681 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 (Russian)

Currently translated at 41.2% (281 of 681 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 37.4% (255 of 681 strings)

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

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

Currently translated at 100.0% (744 of 744 strings)

feat(lang): translated using Weblate (English)

Currently translated at 100.0% (737 of 737 strings)

feat(lang): translated using Weblate (English)

Currently translated at 100.0% (737 of 737 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/en/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 59.4% (405 of 681 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marcos <silvamarcos1919@outlook.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% (742 of 742 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (744 of 744 strings)

feat(lang): translated using Weblate (German)

Currently translated at 99.3% (739 of 744 strings)

feat(lang): translated using Weblate (German)

Currently translated at 99.8% (736 of 737 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (737 of 737 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (681 of 681 strings)

feat(lang): translated using Weblate (German)

Currently translated at 98.5% (671 of 681 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (670 of 670 strings)

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

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: NGVICIOUS <giyomu1@gmail.com>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Co-authored-by: Artem <KovalevArtem.ru@gmail.com>
Co-authored-by: Paul Hagedorn <panzer1119@gmail.com>
2021-02-20 10:47:23 -05:00
TheCatLady
bbea522499 fix(notif): Remove extra newlines from Telegram notifications (#973) 2021-02-21 00:40:50 +09:00
TheCatLady
139341b043 feat(ui): Add support for requesting collections in 4K (#968) 2021-02-20 13:59:44 +00:00
allcontributors[bot]
2e35aff903 docs: add TheCatLady as a contributor (#983) [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>
2021-02-20 19:36:39 +09:00
sct
c7765528b0 chore(github): update CODEOWNERS (Again) [skip ci] 2021-02-20 10:34:12 +00:00
sct
263c31c8b5 chore(github): update CODEOWNERS [skip ci] 2021-02-20 10:32:39 +00:00
TheCatLady
5b64655776 fix(lang): Remove unused strings and correct spelling of 'canceling'/'canceled' (#981) 2021-02-20 19:12:11 +09:00
TheCatLady
4e2706b421 fix(ui/notif): Custom application title in password-related emails and UI messages (#979) 2021-02-20 00:26:26 +00:00
TheCatLady
c700aa938f docs: Add language profile requirement info to FAQ (#980)
* Add language profile requirement info to FAQ

* Fix CODEOWNERS
2021-02-20 08:53:09 +09:00
sct
57c00c1ea7 fix(ui): also allow 17 digit discord ids
fixes #971
2021-02-19 09:17:39 +00:00
TheCatLady
b4bac6a915 fix(frontend): Update AdvancedRequester to reflect new /user API response (#970) 2021-02-19 07:48:06 +00:00
TheCatLady
e70a4ecae6 fix(ui): Add tip & validation for Discord ID input (#966) 2021-02-19 11:42:52 +09:00
sct
17983837fc fix(api): sort users requests by most recent 2021-02-19 02:10:46 +00:00
TheCatLady
621db89328 feat(ui): Add 'Page Size' setting for request/user list pages (#957) 2021-02-19 01:37:08 +00:00
TheCatLady
77b2d9ea22 fix(frontend): Do not allow user w/ ID 1 to disable 'Admin' permission (#965) 2021-02-19 10:20:32 +09:00
TheCatLady
bdb3cb2025 fix(plex): Check Plex server access on user import (#955) 2021-02-18 15:14:37 +00:00
dependabot[bot]
bddea7ab24 build(deps): bump technote-space/workflow-conclusion-action (#822)
Bumps [technote-space/workflow-conclusion-action](https://github.com/technote-space/workflow-conclusion-action) from v1 to v2.1.2.
- [Release notes](https://github.com/technote-space/workflow-conclusion-action/releases)
- [Changelog](https://github.com/technote-space/workflow-conclusion-action/blob/master/.releasegarc)
- [Commits](https://github.com/technote-space/workflow-conclusion-action/compare/v1...cb07f962e292a266fc492bf93a6fb2a7c1f5aa2e)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-18 15:07:36 +00:00
dependabot[bot]
8a9d794fec build(deps): bump diddlesnaps/snapcraft-review-tools-action (#821)
Bumps [diddlesnaps/snapcraft-review-tools-action](https://github.com/diddlesnaps/snapcraft-review-tools-action) from v1 to v1.2.0.
- [Release notes](https://github.com/diddlesnaps/snapcraft-review-tools-action/releases)
- [Commits](https://github.com/diddlesnaps/snapcraft-review-tools-action/compare/v1...7c8e0d11cc9e8833be2b9be8f15602c0bf1b033c)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-18 15:02:58 +00:00
sct
956ccb6a56 chore: add CODEOWNERS [skip ci] 2021-02-18 14:56:46 +00:00
sct
a1765c4ac2 build(deps): bump dependencies 2021-02-18 14:50:04 +00:00
sct
9b73423d49 fix(sync): fix sonarr/plex sync fighting over availability
fixes #872
2021-02-18 14:26:12 +00:00
sct
8a27c70625 fix(api): add isAuthenticated middleware to base user route 2021-02-18 10:12:53 +00:00
TheCatLady
2bcc3e3419 refactor(ui): Create PlayButton component (#946) 2021-02-18 15:40:14 +09:00
sct
bbb683e637 feat: user profile/settings pages (#958) 2021-02-18 11:38:24 +09:00
TheCatLady
29b97ef6d8 feat(notif): Add Pushbullet notification agent (#950) 2021-02-18 11:26:22 +09:00
TheCatLady
c9a150b1db fix(ui): Automatically disable and uncheck user permissions with unmet requirements (#941) 2021-02-18 01:53:12 +09:00
Brandon Cohen
e28dfadaf5 fix(frontend): request and cog button would be misaligned without play on plex/watch trailer button (#956)
* fix(frontend): request and cog button would be misaligned without play on plex/watch trailer button

* fix(frontend): request and cog button would be misaligned without play on plex/watch trailer button

* Update src/components/TvDetails/index.tsx

Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>

* fix(frontend): removed z-index from buttonWithDropdown div

Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
2021-02-17 17:39:21 +09:00
Brandon Cohen
f1c335815f fix(frontend): changed plex, request, and cog buttons to align properly on smaller mobile UIs (#928) 2021-02-16 16:16:14 +09:00
TheCatLady
019622aab1 fix(notif): Do not HTML-escape email subjects (#931) 2021-02-15 13:52:10 +00:00
Jakob Ankarhem
5e37a96bc0 fix(auth): handle sign-in attempts from emails with no password (#933)
* fix(auth): dont reject promise when missing password

* fix(auth): use static fallback error message
2021-02-15 17:34:40 +09:00
sct
8a7fa00164 fix(lang): formatMessage should not use an object spread 2021-02-15 08:24:17 +00:00
sct
2c403e8e13 chore(github): modify workflow for incomplete templates [skip ci] 2021-02-15 08:11:36 +00:00
samwiseg0
577cbcc7fa build(snap): Disable arm64 snap build. (#935)
* build(snap): Disable arm64 snap build.

* build(snap): Add cancel workflow

* docs(issues): Close invalid issues.

* build(snap): Move if to the first job
2021-02-15 16:49:50 +09:00
sct
e118501bf1 fix(requests): correctly filter requests out for users without view requests permission 2021-02-15 01:57:20 +00:00
allcontributors[bot]
50c143d959 docs: add ankarhem as a contributor (#934) [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>
2021-02-15 09:21:40 +09:00
Mārtiņš Možeiko
03cecb3355 fix(plex-sync): get correct Plex metadata for Hama movie items (#901)
fixes #898
2021-02-15 08:35:05 +09:00
TheCatLady
53b7425f67 feat(ui): Add separate permissions for 4K auto approval (#908)
* Clarify & fix permission descriptions

* Automatically check & disable auto-approve permission options when 'Manage Requests' permission is selected
2021-02-14 21:40:18 +09:00
sct
28090c30a7 chore(github): update issue template to remove alternative question [skip ci] 2021-02-14 12:17:44 +00:00
sct
fb9c878db4 fix(ui): change font size in request list/user list dropdowns to prevent zoom on mobile 2021-02-14 11:48:28 +00:00
TheCatLady
9757e3ae0c feat(ui): Add 'Available' filter to request list and remove unused MediaRequestStatus.AVAILABLE enum value (#905) 2021-02-14 19:21:49 +09:00
TheCatLady
ef5d019c18 feat(ui): Add sort options to user list (#913) 2021-02-14 09:49:14 +09:00
Danshil Mungur
04fa9f79e2 fix(frontend): handle empty array of media attributes (#922) 2021-02-13 07:01:35 +09:00
TheCatLady
2768155bba feat(notif): Notification improvements (#914) 2021-02-12 22:27:11 +09:00
Danshil Mungur
7d3da1a1d2 docs: update documentation for recently implemented features (#902) [skip ci] 2021-02-12 19:24:58 +09:00
sct
85486a6719 build(deps): revert next back to 10.0.3 2021-02-10 10:46:32 +00:00
sct
bc4849ef55 build(deps): revert husky back to 4.3.8 2021-02-10 07:44:48 +00:00
sct
1d7ef6d44f build(deps): bump dependencies
this includes an update to husky v5. we currently cant use commitizen with it but that
was causing a ton of issues anyways. so lets try without it for some time.
2021-02-10 06:36:59 +00:00
Weblate (bot)
e156acc1ae feat(lang): translations update from Weblate (#853)
* feat(lang): translated using Weblate (Chinese (Traditional))

Currently translated at 96.2% (643 of 668 strings)

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

Currently translated at 96.1% (642 of 668 strings)

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

Currently translated at 94.9% (634 of 668 strings)

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

Currently translated at 94.8% (626 of 660 strings)

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

Currently translated at 94.8% (626 of 660 strings)

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

Currently translated at 93.3% (616 of 660 strings)

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

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

Currently translated at 100.0% (668 of 668 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 91.3% (610 of 668 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 90.1% (602 of 668 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 84.1% (562 of 668 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 66.8% (441 of 660 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 (English)

Currently translated at 100.0% (660 of 660 strings)

feat(lang): translated using Weblate (English)

Currently translated at 100.0% (660 of 660 strings)

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

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

Currently translated at 99.7% (666 of 668 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.5% (657 of 660 strings)

feat(lang): translated using Weblate (French)

Currently translated at 98.9% (653 of 660 strings)

feat(lang): translated using Weblate (French)

Currently translated at 98.9% (653 of 660 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: Marcos <silvamarcos1919@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 48.1% (318 of 660 strings)

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

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

Currently translated at 100.0% (668 of 668 strings)

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

Currently translated at 100.0% (660 of 660 strings)

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

Currently translated at 100.0% (660 of 660 strings)

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

Currently translated at 100.0% (646 of 646 strings)

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

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

Currently translated at 100.0% (668 of 668 strings)

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

Currently translated at 100.0% (660 of 660 strings)

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

Currently translated at 98.9% (653 of 660 strings)

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

Currently translated at 98.9% (653 of 660 strings)

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

Currently translated at 100.0% (646 of 646 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 97.7% (653 of 668 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 82.5% (545 of 660 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jakob Ankarhem <jakob.ankarhem@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 100.0% (668 of 668 strings)

feat(lang): translated using Weblate (German)

Currently translated at 98.9% (661 of 668 strings)

feat(lang): translated using Weblate (German)

Currently translated at 98.0% (647 of 660 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: Paul Hagedorn <panzer1119@gmail.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% (668 of 668 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (668 of 668 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.1% (662 of 668 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (660 of 660 strings)

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

Co-authored-by: TheCatLady <doris.houng@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Simone Chiavaccini <mazzetta86@gmail.com>
Co-authored-by: K. Herbert <herbert.ka@mailo.com>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: zsmbrvr <easy90rider@gmail.com>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Co-authored-by: Jakob Ankarhem <jakob.ankarhem@outlook.com>
Co-authored-by: Paul Hagedorn <panzer1119@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
2021-02-10 15:14:07 +09:00
Jakob Ankarhem
e037ba48f1 fix(requests): correctly handle when tvdbid is missing (#891)
cleans up media and mediarequest when request to sonarr has failed because of tvdbid
2021-02-10 09:16:53 +09:00
Jakob Ankarhem
034968e437 fix(search): Handle search errors and escape * (#893) 2021-02-10 08:29:35 +09:00
TheCatLady
7e906e2d46 refactor(css): Apply global CSS classes (#874) 2021-02-09 23:57:19 +09:00
Danshil Mungur
c8b2a57721 feat(ui): added content ratings for tv shows and movie ratings (#878) 2021-02-09 17:24:07 +09:00
TheCatLady
4e03c1efcf build(docker): Leverage GitHub cache using actions/cache (#892) 2021-02-09 14:23:17 +09:00
TheCatLady
ff0b5ed441 fix(api): Use POST instead of GET for API endpoints that mutate state (#877) 2021-02-09 10:27:48 +09:00
Jakob Ankarhem
d163e29459 fix(login): fix the gap when 'use your overseer account' was selected (#870) 2021-02-09 09:08:08 +09:00
Danshil Mungur
ee5076146e fix(ui): fixed anime language profile typo (#879)
Co-authored-by: sct <ryan@sct.dev>
2021-02-08 19:52:30 +09:00
sct
8056187c3c fix(ui): rename global group class to form-group 2021-02-08 09:48:09 +00:00
TheCatLady
a3042f8e1b fix(ui): Fix card sizes on person detail pages (#881) 2021-02-08 16:39:01 +09:00
TheCatLady
282f28f2b9 fix(ui): Size cards appropriately based on base font size (#871) 2021-02-08 15:04:13 +09:00
TheCatLady
7c0ddad653 fix(ui): Handle missing movie/series data (#862) 2021-02-08 12:37:22 +09:00
Jakob Ankarhem
53f6f59798 feat(requests): add language profile support (#860) 2021-02-08 00:33:18 +09:00
sct
8956cb3915 refactor(ui): change search input design (experiment) 2021-02-06 15:07:12 +00:00
TheCatLady
baad19a2c9 fix(ui): fix webhook URL validation regex 2021-02-06 05:34:12 +00:00
Jakob Ankarhem
e5966bd3fb feat(users): add reset password flow (#772) 2021-02-05 23:23:57 +09:00
230 changed files with 16218 additions and 6529 deletions

View File

@@ -225,7 +225,8 @@
"profile": "https://github.com/ankarhem",
"contributions": [
"doc",
"code"
"code",
"translation"
]
},
{
@@ -262,7 +263,8 @@
"profile": "https://github.com/TheCatLady",
"contributions": [
"code",
"translation"
"translation",
"doc"
]
},
{
@@ -301,6 +303,79 @@
"contributions": [
"doc"
]
},
{
"login": "dancarter",
"name": "Daniel Carter",
"avatar_url": "https://avatars.githubusercontent.com/u/4387516?v=4",
"profile": "https://github.com/dancarter",
"contributions": [
"code"
]
},
{
"login": "NuroDev",
"name": "nuro",
"avatar_url": "https://avatars.githubusercontent.com/u/4991309?v=4",
"profile": "https://nuro.dev",
"contributions": [
"doc"
]
},
{
"login": "onedr0p",
"name": "ᗪєνιη ᗷυнʟ",
"avatar_url": "https://avatars.githubusercontent.com/u/213795?v=4",
"profile": "https://github.com/onedr0p",
"contributions": [
"infra"
]
},
{
"login": "JonnyWong16",
"name": "JonnyWong16",
"avatar_url": "https://avatars.githubusercontent.com/u/9099342?v=4",
"profile": "https://github.com/JonnyWong16",
"contributions": [
"doc"
]
},
{
"login": "Roxedus",
"name": "Roxedus",
"avatar_url": "https://avatars.githubusercontent.com/u/7110194?v=4",
"profile": "https://github.com/Roxedus",
"contributions": [
"doc"
]
},
{
"login": "WoisWoi",
"name": "WoisWoi",
"avatar_url": "https://avatars.githubusercontent.com/u/75491231?v=4",
"profile": "https://github.com/WoisWoi",
"contributions": [
"translation"
]
},
{
"login": "HubDuck",
"name": "HubDuck",
"avatar_url": "https://avatars.githubusercontent.com/u/77843475?v=4",
"profile": "https://github.com/HubDuck",
"contributions": [
"translation"
]
},
{
"login": "costaht",
"name": "costaht",
"avatar_url": "https://avatars.githubusercontent.com/u/50637431?v=4",
"profile": "https://github.com/costaht",
"contributions": [
"doc",
"translation"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

12
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,12 @@
# Global code ownership
* @sct
# Documentation
docs/ @TheCatLady @samwiseg0
# Snap-related files
.github/workflows/snap.yaml @samwiseg0
snap/ @samwiseg0
# i18n locale files
src/i18n/locale/ @sct @TheCatLady

View File

@@ -1,45 +1,45 @@
---
name: Bug report
about: Create a report to help us improve
about: Submit a report to help us improve
title: ''
labels: 'awaiting-triage, type:bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
#### Description
**What version of Overseerr are you running?**
Please fill in the version you are currently running.
Please provide a clear and concise description of the bug or issue.
You can find it under: Settings -> About -> Version
#### Version
**To Reproduce**
Steps to reproduce the behavior:
What version of Overseerr are you running? (You can find this in Settings → About → Version.)
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
#### Steps to Reproduce
**Expected behavior**
A clear and concise description of what you expected to happen.
Please tell us how we can reproduce the undesired behavior.
**Screenshots**
If applicable, add screenshots to help explain your problem.
1. Go to [...]
2. Click on [...]
3. Scroll down to [...]
4. See error in [...]
**Desktop (please complete the following information):**
#### Expected Behavior
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
Please provide a clear and concise description of what you expected to happen.
**Smartphone (please complete the following information):**
#### Screenshots
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
If applicable, please provide screenshots depicting the problem.
**Additional context**
Add any other context about the problem here.
#### Device
What device were you using when you encountered this issue? Please provide this information to help us reproduce and investigate the bug.
- **Platform:** [e.g., desktop, smartphone, tablet]
- **Device:** [e.g., iPhone X, Surface Pro, Samsung Galaxy Tab]
- **OS:** [e.g., iOS 8.1, Windows 10, Android 11]
- **Browser:** [e.g., Chrome, Safari, Edge, Firefox]
#### Additional Context
Please provide any additional information that may be relevant or helpful.

View File

@@ -6,14 +6,14 @@ labels: 'awaiting-triage, type:enhancement'
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
#### Description
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
Is your feature request related to a problem? If so, please provide a clear and concise description of the problem. E.g., "I'm always frustrated when [...]."
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
#### Desired Behavior
**Additional context**
Add any other context or screenshots about the feature request here.
Provide a clear and concise description of what you want to happen.
#### Additional Context
Provide any additional information or screenshots that may be relevant or helpful.

View File

@@ -1,13 +1,13 @@
#### Description
#### Screenshot (if UI related)
#### Screenshot (if UI-related)
#### Todos
#### To-Dos
- [ ] Sucessfully builds `yarn build`
- [ ] Translation Keys `yarn i18n:extract`
- [ ] Database migration created (if required)
- [ ] Successful build `yarn build`
- [ ] Translation keys `yarn i18n:extract`
- [ ] Database migration (if required)
#### Issues Fixed or Closed by this PR
#### Issues Fixed or Closed
- Fixes #XXXX

2
.github/stale.yml vendored
View File

@@ -8,7 +8,7 @@ exemptLabels:
- security
- dependencies
# Label to use when marking an issue as stale
staleLabel: wontfix
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had

View File

@@ -24,6 +24,7 @@ jobs:
run: yarn lint
- name: build
run: yarn build
build_and_push:
name: Build & Publish to Docker Hub
needs: test
@@ -36,6 +37,13 @@ jobs:
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2.1.4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to DockerHub
uses: docker/login-action@v1
with:
@@ -52,6 +60,7 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}
@@ -60,6 +69,16 @@ jobs:
sctx/overseerr:${{ github.sha }}
ghcr.io/sct/overseerr:develop
ghcr.io/sct/overseerr:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- # Temporary fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
discord:
name: Send Discord Notification
needs: build_and_push
@@ -67,8 +86,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v1
uses: technote-space/workflow-conclusion-action@v2.1.5
- name: Combine Job Status
id: status
run: |
@@ -78,7 +96,6 @@ jobs:
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1
with:

View File

@@ -13,7 +13,7 @@ jobs:
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
:wave: @{issue-author}, please follow the template provided.
close-issue: true
lock-issue: true
issue-lock-reason: 'resolved'

View File

@@ -4,26 +4,27 @@ on:
push:
branches:
- master
- arm-test
jobs:
test:
name: Lint & Test Build
runs-on: ubuntu-20.04
container: node:12.18-alpine
steps:
- name: checkout
uses: actions/checkout@v2
- name: install dependencies
env:
HUSKY_SKIP_INSTALL: 1
run: yarn
- name: lint
run: yarn lint
- name: build
run: yarn build
# test:
# name: Lint & Test Build
# runs-on: ubuntu-20.04
# container: node:12.18-alpine
# steps:
# - name: checkout
# uses: actions/checkout@v2
# - name: install dependencies
# env:
# HUSKY_SKIP_INSTALL: 1
# run: yarn
# - name: lint
# run: yarn lint
# - name: build
# run: yarn build
semantic-release:
name: Tag and release latest version
needs: test
# needs: test
runs-on: ubuntu-20.04
steps:
- name: Checkout
@@ -31,9 +32,24 @@ jobs:
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v1
uses: actions/setup-node@v2
with:
node-version: 12
- 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_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: Install dependencies
run: yarn
- name: Release
@@ -42,63 +58,65 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: npx semantic-release
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: semantic-release
runs-on: ubuntu-20.04
strategy:
fail-fast: false
matrix:
architecture:
- amd64
- arm64
- armhf
steps:
- name: Checkout Code
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Switch to master branch
run: git checkout master
- name: Pull latest changes
run: git pull
# build-snap:
# name: Build Snap Package (${{ matrix.architecture }})
# needs: semantic-release
# runs-on: ubuntu-20.04
# strategy:
# fail-fast: false
# matrix:
# architecture:
# - amd64
# - arm64
# - armhf
# steps:
# - name: Checkout Code
# uses: actions/checkout@v2
# with:
# fetch-depth: 0
# - name: Switch to master branch
# run: git checkout master
# - name: Pull latest changes
# run: git pull
- name: Prepare
id: prepare
run: |
git fetch --prune --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo ::set-output name=RELEASE::stable
else
echo ::set-output name=RELEASE::edge
fi
# - name: Prepare
# id: prepare
# run: |
# git fetch --prune --tags
# if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
# echo ::set-output name=RELEASE::stable
# else
# echo ::set-output name=RELEASE::edge
# fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v1
# - name: Set Up QEMU
# uses: docker/setup-qemu-action@v1
# with:
# image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
- name: Build Snap Package
uses: diddlesnaps/snapcraft-multiarch-action@v1
id: build
with:
architecture: ${{ matrix.architecture }}
# - name: Build Snap Package
# uses: diddlesnaps/snapcraft-multiarch-action@v1
# id: build
# with:
# architecture: ${{ matrix.architecture }}
- name: Upload Snap Package
uses: actions/upload-artifact@v2
with:
name: overseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
# - name: Upload Snap Package
# uses: actions/upload-artifact@v2
# with:
# name: overseerr-snap-package-${{ matrix.architecture }}
# path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1
with:
snap: ${{ steps.build.outputs.snap }}
# - name: Review Snap Package
# uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0
# with:
# snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
uses: snapcore/action-publish@v1
with:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
# - name: Publish Snap Package
# uses: snapcore/action-publish@v1
# with:
# store_login: ${{ secrets.SNAP_LOGIN }}
# snap: ${{ steps.build.outputs.snap }}
# release: ${{ steps.prepare.outputs.RELEASE }}
discord:
name: Send Discord Notification
needs: semantic-release
@@ -106,7 +124,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v1
uses: technote-space/workflow-conclusion-action@v2.1.5
- name: Combine Job Status
id: status

View File

@@ -5,10 +5,19 @@ on:
branches: [develop]
jobs:
test:
name: Lint & Test Build
jobs:
name: Job Check
runs-on: ubuntu-20.04
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.8.0
with:
access_token: ${{ secrets.GITHUB_TOKEN }}
test:
name: Lint & Test Build
needs: jobs
runs-on: ubuntu-20.04
container: node:12.18-alpine
steps:
- name: checkout
@@ -48,6 +57,8 @@ jobs:
- name: Set Up QEMU
uses: docker/setup-qemu-action@v1
with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
- name: Build Snap Package
uses: diddlesnaps/snapcraft-multiarch-action@v1
@@ -62,7 +73,7 @@ jobs:
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1
uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0
with:
snap: ${{ steps.build.outputs.snap }}
@@ -79,7 +90,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v1
uses: technote-space/workflow-conclusion-action@v2.1.5
- name: Combine Job Status
id: status

View File

@@ -19,7 +19,8 @@ jobs:
to get help with Overseerr.
- [Discord](https://discord.gg/PkCWJSeCk7)
- [GitHub Discussions](https://github.com/sct/overseerr/discussions)
close-issue: true
lock-issue: false
lock-issue: true
issue-lock-reason: 'off-topic'

2
.gitignore vendored
View File

@@ -38,6 +38,8 @@ config/settings.json
# logs
config/logs/*.log*
config/logs/*.json
config/logs/*.log.gz
config/logs/*-audit.json
# anidb mapping file
config/anime-list.xml

View File

@@ -1,3 +1,84 @@
## [1.20.1](https://github.com/sct/overseerr/compare/v1.20.0...v1.20.1) (2021-02-28)
### Bug Fixes
* **notif:** escape application title in Telegram notifications ([#1012](https://github.com/sct/overseerr/issues/1012)) ([5560abf](https://github.com/sct/overseerr/commit/5560abf459b0350ff30b5e71d4208418fc8f3b3e))
* **notif:** fixed typo in pushover hint ([#1029](https://github.com/sct/overseerr/issues/1029)) ([e9f2fe9](https://github.com/sct/overseerr/commit/e9f2fe910d72fa41bc27673ed43291211c3cac65))
* **notifications:** correctly send tv auto approval notifications ([537850f](https://github.com/sct/overseerr/commit/537850f414a88df24c78794a2fd68e1e24ff73d1)), closes [#1041](https://github.com/sct/overseerr/issues/1041)
* **plex-sync:** no longer incorrectly sets 4k availability when there isnt any ([3f9a116](https://github.com/sct/overseerr/commit/3f9a116b17d78eeb04f0f125a4f3af6f907c83dd)), closes [#990](https://github.com/sct/overseerr/issues/990)
* **ui:** for server default options, display "All" region/language option instead of empty string ([#1042](https://github.com/sct/overseerr/issues/1042)) ([3fed26c](https://github.com/sct/overseerr/commit/3fed26cfbe74cb662ca531fd37b69f159a051ac1))
* **ui:** show translated string on sonarr sucesss/failure toast messages ([#1035](https://github.com/sct/overseerr/issues/1035)) ([eefcbcd](https://github.com/sct/overseerr/commit/eefcbcd3ddfa5258ee24dbbbd79de5bf50310f27))
* **ui:** use country-flag-icons instead of country-flag-emoji for RegionSelector ([#1011](https://github.com/sct/overseerr/issues/1011)) ([abcd7c9](https://github.com/sct/overseerr/commit/abcd7c997584c1310bd8b313ac38f30e335af8d7))
* add missing default value for settings context ([084917f](https://github.com/sct/overseerr/commit/084917f02d399e2d29bb9927e033c2e6533f586c))
* added missing language default for ssr context defaults ([9ce88ab](https://github.com/sct/overseerr/commit/9ce88abcc85d744d77172cd2357fdb4ff60dc5e4))
* allow users to override language/region settings ([69294a7](https://github.com/sct/overseerr/commit/69294a7c4c5bbe55c5cd276786cdfd48ddbff889)), closes [#1013](https://github.com/sct/overseerr/issues/1013)
# [1.20.0](https://github.com/sct/overseerr/compare/v1.19.1...v1.20.0) (2021-02-23)
### Bug Fixes
* **api:** add isAuthenticated middleware to base user route ([8a27c70](https://github.com/sct/overseerr/commit/8a27c7062599ea23dca115e6e6e95a594e1b219a))
* **api:** sort users requests by most recent ([1798383](https://github.com/sct/overseerr/commit/17983837fc10661a59d29fc1531530fca0d77825))
* **api:** Use POST instead of GET for API endpoints that mutate state ([#877](https://github.com/sct/overseerr/issues/877)) ([ff0b5ed](https://github.com/sct/overseerr/commit/ff0b5ed44132cc5a0cd178035796d042ba735a8d))
* **auth:** handle sign-in attempts from emails with no password ([#933](https://github.com/sct/overseerr/issues/933)) ([5e37a96](https://github.com/sct/overseerr/commit/5e37a96bc017471f8dc4cbdd57f2e8c3568bd97f))
* **frontend:** changed plex, request, and cog buttons to align properly on smaller mobile UIs ([#928](https://github.com/sct/overseerr/issues/928)) ([f1c3358](https://github.com/sct/overseerr/commit/f1c335815f2f17465cdd36ceb223e78a58149b3b))
* **frontend:** check for id instead of email after logging in ([c4af4c4](https://github.com/sct/overseerr/commit/c4af4c42ab00f1a63a2f5326c9cd8b26c19f4f14))
* **frontend:** Do not allow user w/ ID 1 to disable 'Admin' permission ([#965](https://github.com/sct/overseerr/issues/965)) ([77b2d9e](https://github.com/sct/overseerr/commit/77b2d9ea22a2f70cff58ac9421f3f6231bc93059))
* **frontend:** handle empty array of media attributes ([#922](https://github.com/sct/overseerr/issues/922)) ([04fa9f7](https://github.com/sct/overseerr/commit/04fa9f79e2ec90082b3fa15590dd170f7d68ad52))
* **frontend:** request and cog button would be misaligned without play on plex/watch trailer button ([#956](https://github.com/sct/overseerr/issues/956)) ([e28dfad](https://github.com/sct/overseerr/commit/e28dfadaf57d47887013c31dc5006332473156e3))
* **frontend:** Update AdvancedRequester to reflect new /user API response ([#970](https://github.com/sct/overseerr/issues/970)) ([b4bac6a](https://github.com/sct/overseerr/commit/b4bac6a9157119a4f234933245944e133c127bd0))
* **frontend:** use region settings instead of hardcoded 'US' value for movie/TV ratings ([#1006](https://github.com/sct/overseerr/issues/1006)) ([6ecd202](https://github.com/sct/overseerr/commit/6ecd202607cb48d559440da810ecc585e740542b))
* **lang:** formatMessage should not use an object spread ([8a7fa00](https://github.com/sct/overseerr/commit/8a7fa00164fd5c5501da525baa29be97bac7e7c4))
* **lang:** Remove unused strings and correct spelling of 'canceling'/'canceled' ([#981](https://github.com/sct/overseerr/issues/981)) ([5b64655](https://github.com/sct/overseerr/commit/5b646557765d1ad75e44e1c0e60e0291313c7746))
* **login:** fix the gap when 'use your overseer account' was selected ([#870](https://github.com/sct/overseerr/issues/870)) ([d163e29](https://github.com/sct/overseerr/commit/d163e294599c4bd9bdc0a148db15c8e8541410d8))
* **notif:** Do not HTML-escape email subjects ([#931](https://github.com/sct/overseerr/issues/931)) ([019622a](https://github.com/sct/overseerr/commit/019622aab1b94cc4d71cacbf0dc5cf64b62c8623))
* **notif:** Remove extra newlines from Telegram notifications ([#973](https://github.com/sct/overseerr/issues/973)) ([bbea522](https://github.com/sct/overseerr/commit/bbea52249950eb98a8d3886f2bd7648a7d669bf4))
* **plex:** Check Plex server access on user import ([#955](https://github.com/sct/overseerr/issues/955)) ([bdb3cb2](https://github.com/sct/overseerr/commit/bdb3cb202550e34d8951ac2b5015f97f6a5c1ebf))
* **plex-sync:** get correct Plex metadata for Hama movie items ([#901](https://github.com/sct/overseerr/issues/901)) ([03cecb3](https://github.com/sct/overseerr/commit/03cecb33559e27199c5a174fc86de0c4550fe666)), closes [#898](https://github.com/sct/overseerr/issues/898)
* **requests:** correctly filter requests out for users without view requests permission ([e118501](https://github.com/sct/overseerr/commit/e118501bf1dfa8dada2c57090e62631de620f3dd))
* **requests:** correctly handle when tvdbid is missing ([#891](https://github.com/sct/overseerr/issues/891)) ([e037ba4](https://github.com/sct/overseerr/commit/e037ba48f173c06b0c9c8b03085edf832d770c06))
* **search:** Handle search errors and escape * ([#893](https://github.com/sct/overseerr/issues/893)) ([034968e](https://github.com/sct/overseerr/commit/034968e4370eaea726c94730274349c083856813))
* **services:** update all radarr/sonarr endpoints to use v3 ([da5ca02](https://github.com/sct/overseerr/commit/da5ca02f81fe91070afbda3e1ebc8d869fe39a8f))
* **sonarr:** use qualityProfileId instad of profileId when adding series ([552a7e3](https://github.com/sct/overseerr/commit/552a7e30da5fc2cc0bd43b5aef79a0225c75d233))
* **sync:** fix sonarr/plex sync fighting over availability ([9b73423](https://github.com/sct/overseerr/commit/9b73423d49e1e799cd82764a9ade8c75d92a28a2)), closes [#872](https://github.com/sct/overseerr/issues/872)
* **ui:** add fallback for region display name ([f9c83e1](https://github.com/sct/overseerr/commit/f9c83e14e52a57d6865307b3324a61c04a77a541))
* **ui:** add missing string for default Discover Language & edit string for default Discover Region ([#1004](https://github.com/sct/overseerr/issues/1004)) ([0acad8e](https://github.com/sct/overseerr/commit/0acad8e9fa65a9de6cecac9b6a4a5b2313ba8f06))
* **ui:** Add tip & validation for Discord ID input ([#966](https://github.com/sct/overseerr/issues/966)) ([e70a4ec](https://github.com/sct/overseerr/commit/e70a4ecae613e045977e262fd7f9643f30985ab7))
* **ui:** also allow 17 digit discord ids ([57c00c1](https://github.com/sct/overseerr/commit/57c00c1ea71c1229d5a59e1b8dadd84a646772b9)), closes [#971](https://github.com/sct/overseerr/issues/971)
* **ui:** Automatically disable and uncheck user permissions with unmet requirements ([#941](https://github.com/sct/overseerr/issues/941)) ([c9a150b](https://github.com/sct/overseerr/commit/c9a150b1db2adbb305cf1a448489d7a8c14cf1cb))
* **ui:** change font size in request list/user list dropdowns to prevent zoom on mobile ([fb9c878](https://github.com/sct/overseerr/commit/fb9c878db49c01d13773e8d2f94c93f840be0b82))
* **ui:** Display 4K download status on 4K status badge ([#988](https://github.com/sct/overseerr/issues/988)) ([40b07c3](https://github.com/sct/overseerr/commit/40b07c35d40c03039e4bfa5ed1e73af7e8aa6a7d))
* **ui:** Fix card sizes on person detail pages ([#881](https://github.com/sct/overseerr/issues/881)) ([a3042f8](https://github.com/sct/overseerr/commit/a3042f8e1b05a91d98f48a4aecb08e831a48fc56))
* **ui:** Fix settings navigation horizontal scroll issues ([#987](https://github.com/sct/overseerr/issues/987)) ([8701fb2](https://github.com/sct/overseerr/commit/8701fb20d07773f4cc32e857b68575a813cf7e21))
* **ui:** fix webhook URL validation regex ([baad19a](https://github.com/sct/overseerr/commit/baad19a2c94728313ee996fe1a0ffc64fbd9aaa3))
* **ui:** fixed anime language profile typo ([#879](https://github.com/sct/overseerr/issues/879)) ([ee50761](https://github.com/sct/overseerr/commit/ee5076146ef3c5e8baba197a5b397d3c3f575262))
* **ui:** Handle missing movie/series data ([#862](https://github.com/sct/overseerr/issues/862)) ([7c0ddad](https://github.com/sct/overseerr/commit/7c0ddad653393327226a877692f046d8693ddc66))
* **ui:** Notification-related string/UI edits and field validation ([#985](https://github.com/sct/overseerr/issues/985)) ([c88fcb2](https://github.com/sct/overseerr/commit/c88fcb2e2d1c4b84527844a80680c15337626e72))
* **ui:** rename global group class to form-group ([8056187](https://github.com/sct/overseerr/commit/8056187c3c0ea464a8f751aa6347ea1d35c01aac))
* **ui:** Size cards appropriately based on base font size ([#871](https://github.com/sct/overseerr/issues/871)) ([282f28f](https://github.com/sct/overseerr/commit/282f28f2b9d0cc8c9105d01b43d4e1f730320b8b))
* **ui/notif:** Custom application title in password-related emails and UI messages ([#979](https://github.com/sct/overseerr/issues/979)) ([4e2706b](https://github.com/sct/overseerr/commit/4e2706b4211b06f364910c327d84c2ceb45b2fe3))
### Features
* **lang:** translated using Weblate (French) ([#1007](https://github.com/sct/overseerr/issues/1007)) ([970da66](https://github.com/sct/overseerr/commit/970da664b2700b8cd9ad8dce0cbca1d37820eceb))
* **lang:** translations update from Weblate ([#853](https://github.com/sct/overseerr/issues/853)) ([e156acc](https://github.com/sct/overseerr/commit/e156acc1ae2fa86b4441faacc0b58e1e993e0edc))
* **lang:** translations update from Weblate ([#986](https://github.com/sct/overseerr/issues/986)) ([4296765](https://github.com/sct/overseerr/commit/4296765ad61bac09c2317b71b763366d328733e4))
* **notif:** Add Pushbullet notification agent ([#950](https://github.com/sct/overseerr/issues/950)) ([29b97ef](https://github.com/sct/overseerr/commit/29b97ef6d85bbea31dd59b7ad857b0d8ab30bff0))
* **notif:** Notification improvements ([#914](https://github.com/sct/overseerr/issues/914)) ([2768155](https://github.com/sct/overseerr/commit/2768155bbabe121a4c51fc1472461cd5114c4300))
* **regions:** add region/original language setting for filtering Discover ([#732](https://github.com/sct/overseerr/issues/732)) ([#942](https://github.com/sct/overseerr/issues/942)) ([b557c06](https://github.com/sct/overseerr/commit/b557c06b0a78f5df5f64a05dc1e4511dae72df4f))
* **requests:** add language profile support ([#860](https://github.com/sct/overseerr/issues/860)) ([53f6f59](https://github.com/sct/overseerr/commit/53f6f59798fa7e3f95959990a3df555db3c1c51e))
* **ui:** Add 'Available' filter to request list and remove unused MediaRequestStatus.AVAILABLE enum value ([#905](https://github.com/sct/overseerr/issues/905)) ([9757e3a](https://github.com/sct/overseerr/commit/9757e3ae0c572fb46177e25154b29e0ceced665f))
* **ui:** Add 'Page Size' setting for request/user list pages ([#957](https://github.com/sct/overseerr/issues/957)) ([621db89](https://github.com/sct/overseerr/commit/621db893281f0280fe773ac7dbdc44434895242c))
* **ui:** Add separate permissions for 4K auto approval ([#908](https://github.com/sct/overseerr/issues/908)) ([53b7425](https://github.com/sct/overseerr/commit/53b7425f6711e250935e7bb024c38ff6c62e07d9))
* **ui:** Add sort options to user list ([#913](https://github.com/sct/overseerr/issues/913)) ([ef5d019](https://github.com/sct/overseerr/commit/ef5d019c18d7f6cdbbb1e1b7f8ff7816ed9b117b))
* **ui:** Add support for requesting collections in 4K ([#968](https://github.com/sct/overseerr/issues/968)) ([139341b](https://github.com/sct/overseerr/commit/139341b0434b41e7c31af36baacd8d65566a6a0c))
* user profile/settings pages ([#958](https://github.com/sct/overseerr/issues/958)) ([bbb683e](https://github.com/sct/overseerr/commit/bbb683e637386ad8bbeb44dca97aac9cdaf11349))
* **ui:** added content ratings for tv shows and movie ratings ([#878](https://github.com/sct/overseerr/issues/878)) ([c8b2a57](https://github.com/sct/overseerr/commit/c8b2a57721a51adcc7f90ec1acb48b127991d467))
* **users:** add reset password flow ([#772](https://github.com/sct/overseerr/issues/772)) ([e5966bd](https://github.com/sct/overseerr/commit/e5966bd3fbfe172f264f4e986ad2aecf29ae1510))
## [1.19.1](https://github.com/sct/overseerr/compare/v1.19.0...v1.19.1) (2021-02-06)

View File

@@ -1,78 +1,105 @@
# Contributing to Overseerr
All help is welcome and greatly appreciated. If you would like to contribute to the project the steps below can get you started:
All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
## Development
### Tools Required
- HTML/Typescript/Javascript editor of choice. ([VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.)
- [NodeJS](https://nodejs.org/en/download/) (Node 12.x.x or higher)
- HTML/Typescript/Javascript editor
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
- [NodeJS](https://nodejs.org/en/download/) (Node 14.x or higher)
- [Yarn](https://yarnpkg.com/)
- [Git](https://git-scm.com/downloads)
### Getting Started
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
```
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device:
```bash
git clone https://github.com/YOUR_USERNAME/overseerr.git
cd overseerr/
```
2. Add the remote upstream.
```
2. Add the remote `upstream`:
```bash
git remote add upstream https://github.com/sct/overseerr.git
```
3. Create a new branch
3. Create a new branch:
```
```bash
git checkout -b BRANCH_NAME develop
```
- Its recommended to name the branch something relevant to the feature or fix you are working on.
- An example of this would be `fix-title-cards` or `feature-new-system`.
- Bad examples would be `patch` or `bug`.
- It is recommended to give your branch a meaningful name, relevant to the feature or fix you are working on.
- Good examples:
- `docs-docker`
- `feature-new-system`
- `fix-title-cards`
- Bad examples:
- `bug`
- `docs`
- `feature`
- `fix`
- `patch`
4. Run development environment
4. Run the development environment:
```
```bash
yarn
yarn dev
```
- Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
5. Create your patch and run appropriate tests.
5. Create your patch and test your changes.
6. Follow the [guidelines](#contributing-code).
7. Should you need to update your fork you can do so by rebasing from upstream.
```
git fetch upstream
git rebase upstream/develop
git push origin BRANCH_NAME -f
```
- Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines.
- Should you need to update your fork, you can do so by rebasing from `upstream`:
```bash
git fetch upstream
git rebase upstream/develop
git push origin BRANCH_NAME -f
```
### Contributing Code
- If you are taking on an existing bug or feature ticket, please comment on the [GitHub Issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
- All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
- It is okay if you squash your PR down to be a single commit that fits this standard.
- 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 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.
- If you have questions or need help, you can reach out in [GitHub Discussions](https://github.com/sct/overseerr/discussions) or in our [Discord](https://discord.gg/PkCWJSeCk7).
- Only open pull requests to `develop`. Never `master`. Any PR's opened to master will be closed.
- It is okay to squash your pull request down into a single commit that fits this standard.
- Pull requests 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 responsibility to keep your branch up-to-date. Your work will **not** be merged unless it is 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 code base. It should automatically run with a Git hook, but it is recommended to have the Prettier extension installed in your editor and format on save.
- If you have questions or need help, you can reach out via [Discussions](https://github.com/sct/overseerr/discussions) or our [Discord server](https://discord.gg/PkCWJSeCk7).
- Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed.
### UI Text Style
When adding new UI text, please try to adhere to the following guidelines:
1. Be concise and clear, and use as few words as possible to make your point.
2. Use the Oxford comma where appropriate.
3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols.
4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., TMDb and IMDb have a lowercase 'b', whereas TheTVDB has a capital 'B'.
5. Title case headings, button text, and form labels. Note that verbs such as "is" should be capitalized, whereas prepositions like "from" should be lowercase (unless as the first or last word of the string, in which case they are also capitalized).
6. Capitalize the first word in validation error messages, dropdowns, and form "tips." These strings should not end in punctuation.
7. Ensure that toast notification strings are complete sentences ending in punctuation.
8. If an additional description or "tip" is required for a form field, it should be styled using the global CSS class `label-tip`.
9. In full sentences, abbreviations like "info" or "auto" should not be used in place of full words, unless referencing the name/label of a specific setting or option which has an abbreviation in its name.
10. Do your best to check for spelling errors and grammatical mistakes.
11. Do not misspell "Overseerr."
## Translation
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations so please feel free to contribute to localizing Overseerr!
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/sct/overseerr/issues/new/choose).
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a>
## Attribution

View File

@@ -1,4 +1,7 @@
FROM node:14.15-alpine AS BUILD_IMAGE
FROM node:14.16-alpine AS BUILD_IMAGE
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
@@ -6,7 +9,13 @@ ENV COMMIT_TAG=${COMMIT_TAG}
COPY . /app
WORKDIR /app
RUN yarn --frozen-lockfile && \
RUN \
case "${TARGETPLATFORM}" in \
'linux/arm64') apk add --no-cache python make g++ ;; \
'linux/arm/v7') apk add --no-cache python make g++ ;; \
esac
RUN yarn --frozen-lockfile --network-timeout 1000000 && \
yarn build
# remove development dependencies
@@ -20,14 +29,15 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:14.15-alpine
FROM node:14.16-alpine
RUN apk add --no-cache tzdata
RUN apk add --no-cache tzdata tini
# copy from build image
COPY --from=BUILD_IMAGE /app /app
WORKDIR /app
CMD yarn start
ENTRYPOINT [ "/sbin/tini", "--" ]
CMD [ "yarn", "start" ]
EXPOSE 5055

View File

@@ -1,4 +1,4 @@
FROM node:12.18-alpine
FROM node:14.16-alpine
COPY . /app
WORKDIR /app

View File

@@ -6,43 +6,34 @@
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20CI/badge.svg" alt="Overseerr CI">
</p>
<p align="center">
<a href="https://discord.gg/PkCWJSeCk7">
<img src="https://img.shields.io/discord/783137440809746482" alt="Discord">
</a>
<img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls">
<a href="https://hosted.weblate.org/engage/overseerr/">
<img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" />
</a>
<a href="https://discord.gg/PkCWJSeCk7"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://hub.docker.com/r/sctx/overseerr"><img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls"></a>
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://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">
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-32-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)** and **[Plex](https://www.plex.tv/)**!
**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**, and **[Plex](https://www.plex.tv/)**!
## Current Features
- Full Plex integration. Login and manage user access with Plex!
- Easy integration with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come!
- Plex libraries sync to know what titles you already have.
- Complex request system allowing users to request individual seasons or movies in a friendly, easy to use UI.
- Full Plex integration. Authenticate and manage user access with Plex!
- Easy integration with your existing services. Currently, Overseerr supports Sonarr and Radarr. More to come!
- Plex library sync, to keep track of the titles which are already available.
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
- Granular permission system.
- Support for various notification agents.
- Mobile-friendly design, for when you need to approve requests on the go!
## In Development
- User profiles.
- User settings page (to give users the ability to modify their Overseerr experience to their liking).
- Local user system (for those who don't use Plex).
## Planned Features
- More notification types.
- Additional notification types.
- Issues system. This will allow users to report issues with content on your media server.
- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see what features people have already requested.
- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see the features which have already been requested.
## Getting Started
@@ -52,10 +43,11 @@ https://docs.overseerr.dev/getting-started/installation
## Running Overseerr
Currently, Overseerr is primarily distributed as Docker images. If you have Docker, you can run Overseerr with:
Currently, Overseerr is primarily distributed as Docker images. If you have Docker installed, you can simply run Overseerr with:
```
docker run -d \
--name overseerr \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
@@ -64,7 +56,9 @@ docker run -d \
sctx/overseerr
```
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps.
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps
For more information and alternative installation methods, please see the [Overseerr documentation](https://docs.overseerr.dev/getting-started/installation).
⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the `sctx/overseerr:develop` image instead! ⚠️
@@ -83,19 +77,19 @@ After running Overseerr for the first time, configure it by visiting the web UI
Our documentation is built on every commit and hosted at https://api-docs.overseerr.dev
Also, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs
You can also access the API documentation from your local Overseerr install at http://localhost:5055/api-docs
## Community
You can ask questions, share ideas, and more in [GitHub Discussions](https://github.com/sct/overseerr/discussions).
If you would like to chat with community members you can join the [Overseerr Discord](https://discord.gg/PkCWJSeCk7).
If you would like to chat with other members of our growing community, [join the Overseerr Discord server](https://discord.gg/PkCWJSeCk7)!
Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels.
## Contributing
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
You can help improve Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
## Contributors ✨
@@ -135,17 +129,27 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<tr>
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4?s=100" 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?s=100" 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>
<td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a> <a href="#translation-ankarhem" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/hirenshah"><img src="https://avatars2.githubusercontent.com/u/418112?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hirenshah</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hirenshah" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/chriscpritchard"><img src="https://avatars1.githubusercontent.com/u/1839074?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chris Pritchard</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Tamberlox"><img src="https://avatars3.githubusercontent.com/u/56069014?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tamberlox</b></sub></a><br /><a href="#translation-Tamberlox" title="Translation">🌍</a></td>
<td align="center"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
<td align="center"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/dancarter"><img src="https://avatars.githubusercontent.com/u/4387516?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel Carter</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dancarter" title="Code">💻</a></td>
<td align="center"><a href="https://nuro.dev"><img src="https://avatars.githubusercontent.com/u/4991309?v=4?s=100" width="100px;" alt=""/><br /><sub><b>nuro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=NuroDev" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/onedr0p"><img src="https://avatars.githubusercontent.com/u/213795?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ᗪєνιη ᗷυнʟ</b></sub></a><br /><a href="#infra-onedr0p" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/JonnyWong16"><img src="https://avatars.githubusercontent.com/u/9099342?v=4?s=100" width="100px;" alt=""/><br /><sub><b>JonnyWong16</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JonnyWong16" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Roxedus"><img src="https://avatars.githubusercontent.com/u/7110194?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Roxedus</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Roxedus" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/WoisWoi"><img src="https://avatars.githubusercontent.com/u/75491231?v=4?s=100" width="100px;" alt=""/><br /><sub><b>WoisWoi</b></sub></a><br /><a href="#translation-WoisWoi" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/costaht"><img src="https://avatars.githubusercontent.com/u/50637431?v=4?s=100" width="100px;" alt=""/><br /><sub><b>costaht</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=costaht" title="Documentation">📖</a> <a href="#translation-costaht" title="Translation">🌍</a></td>
</tr>
</table>

View File

@@ -19,4 +19,6 @@ The primary motivation for starting this project was to have an incredibly perfo
## We need your help!
Overseerr is an ambitious project. We have already poured a lot of work into this, with more coming. We need your valuable feedback and help with finding bugs. Also, being that this is an open-source project, anyone is welcome to contribute. Contribution includes building features, patching bugs, or even translating the application. You can find the contribution guide on our GitHub.
Overseerr is an ambitious project. We have already poured a lot of work into this, and have a lot more to do. We need your valuable feedback and help to find and fix bugs. Also, with Overseerr being an open-source project, anyone is welcome to contribute. Contribution includes building new features, patching bugs, translating the application, or even just writing documentation.
If you would like to contribute, please be sure to review our [contribution guidelines](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md).

View File

@@ -1,4 +1,4 @@
# Table of contents
# Table of Contents
- [Introduction](README.md)
@@ -13,10 +13,10 @@
## Support
- [Frequently Asked Questions](support/faq.md)
- [Frequently Asked Questions (FAQ)](support/faq.md)
- [Asking for Support](support/asking-for-support.md)
## Extending Overseerr
* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
* [Fail2ban Filter](extending-overseerr/fail2ban.md)
- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
- [Fail2ban Filter](extending-overseerr/fail2ban.md)

View File

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

View File

@@ -1,18 +1,14 @@
# Reverse Proxy Examples
{% hint style="warning" %}
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported.
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported. However, a Nginx subfolder workaround configuration is provided below to use at your own risk.
{% endhint %}
## LE/SWAG
## SWAG
### Subdomain
A sample proxy configuration is included in [SWAG (Secure Web Application Gateway)](https://github.com/linuxserver/docker-swag). However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls).
A sample is bundled in SWAG. This page is still the only source of truth, so the sample is not guaranteed to be up to date. If you catch an inconsistency, report it to the linuxserver team, or do a pull-request against the proxy-confs repository to update the sample.
Rename the sample file `overseerr.subdomain.conf.sample` to `overseerr.subdomain.conf` in the `proxy-confs`folder, or create `overseerr.subdomain.conf` in the same folder with the example below.
Example Configuration:
To use the bundled configuration file, simply rename `overseerr.subdomain.conf.sample` in the `proxy-confs` folder to `overseerr.subdomain.conf`. Alternatively, create a new file `overseerr.subdomain.conf` in `proxy-confs` with the following configuration:
```nginx
server {
@@ -41,11 +37,7 @@ server {
## Traefik \(v2\)
Add the labels to the Overseerr service in your `docker-compose` file. A basic example for a `docker-compose` file using Traefik can be found [here](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
### Subdomain
Example Configuration:
Add the following labels to the Overseerr service in your `docker-compose.yml` file:
```text
labels:
@@ -59,33 +51,16 @@ labels:
- "traefik.http.services.overseerr-svc.loadbalancer.server.port=5055"
```
## LE/NGINX
For more information, see the Traefik documentation for a [basic example](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
### Subdomain
## Nginx
Take the configuration below and place it in `/etc/nginx/sites-available/overseerr.example.com.conf`.
{% tabs %}
{% tab title="Subdomain" %}
Create a symlink to `/etc/nginx/sites-enabled`:
Add the following configuration to a new file `/etc/nginx/sites-available/overseerr.example.com.conf`:
```text
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
```
Test the configuration:
```text
sudo nginx -t
```
Reload your configuration for NGINX:
```text
sudo systemctl reload nginx
```
Example Configuration:
```text
```nginx
server {
listen 80;
server_name overseerr.example.com;
@@ -133,3 +108,61 @@ server {
}
}
```
Then, create a symlink to `/etc/nginx/sites-enabled`:
```bash
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
```
{% endtab %}
{% tab title="Subfolder" %}
{% hint style="warning" %}
Nginx subfolder reverse proxy is unsupported. The sub filters may stop working when Overseerr is updated. Use at your own risk!
{% endhint %}
Add the following location block to your existing `nginx.conf` file.
```nginx
location ^~ /overseerr {
set $app 'overseerr';
# Remove /overseerr path to pass to the app
rewrite ^/overseerr/?(.*)$ /$1 break;
proxy_pass http://127.0.0.1:5055; # NO TRAILING SLASH
# Redirect location headers
proxy_redirect ^ /$app;
proxy_redirect /setup /$app/setup;
proxy_redirect /login /$app/login;
# Sub filters to replace hardcoded paths
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types *;
sub_filter 'href="/"' 'href="/$app"';
sub_filter 'href="/login"' 'href="/$app/login"';
sub_filter 'href:"/"' 'href:"/$app"';
sub_filter '/_next' '/$app/_next';
sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
sub_filter '/images/' '/$app/images/';
sub_filter '/android-' '/$app/android-';
sub_filter '/apple-' '/$app/apple-';
sub_filter '/favicon' '/$app/favicon';
sub_filter '/logo.png' '/$app/logo.png';
sub_filter '/site.webmanifest' '/$app/site.webmanifest';
}
```
{% endtab %}
{% endtabs %}
Next, test the configuration:
```bash
sudo nginx -t
```
Finally, reload `nginx` for the new configuration to take effect:
```bash
sudo systemctl reload nginx
```

View File

@@ -15,6 +15,7 @@ After running Overseerr for the first time, configure it by visiting the web UI
```bash
docker run -d \
--name overseerr \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
@@ -25,10 +26,35 @@ docker run -d \
{% endtab %}
{% tab title="Compose" %}
**docker-compose.yml:**
```yaml
---
version: "3"
services:
overseerr:
image: sctx/overseerr:latest
container_name: overseerr
environment:
- LOG_LEVEL=info
- TZ=Asia/Tokyo
ports:
- 5055:5055
volumes:
- /path/to/appdata/config:/app/config
restart: unless-stopped
```
{% endtab %}
{% tab title="UID/GID" %}
```text
docker run -d \
--name overseerr \
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
@@ -163,6 +189,11 @@ This version can break any moment. Be prepared to troubleshoot any issues that a
{% endtab %}
{% tab title="Swizzin" %}
{% hint style="danger" %}
This implementation is not yet merged to master due to missing functionality. You can beta test the limited implementation or follow the status on [the pull request](https://github.com/swizzin/swizzin/pull/567).
{% endhint %}
The installation is not implemented via Docker, but barebones. The latest release version of Overseerr will be used.
Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information.

View File

@@ -1,34 +1,33 @@
# Asking for Support
## Before asking for support, make sure you try these things first
## Before Asking for Support
* Make sure you have **updated** to the latest version.
* ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
* **Analyzing** your logs, you just might find the solution yourself!
* **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
* If you have questions, feel free to ask them on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md)\). Please include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) for more details.
Before seeking help, please make sure you have tried these following first:
- **Update** to the latest version.
- ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
- **Analyze** your logs, you just might find the solution yourself!
- **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
- If you have questions, feel free to ask on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md).\) Be sure to include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below.
## What should I include when asking for support?
When you contact support saying something like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support try to include information such as the following:
When you contact support, a vague statement like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support, try to include as much information as possible. Try to answer the following questions:
* What did you try to do? When you describe what you did to reach the state you are in we may notice something you did different from the instructions, or something that your unique setup requires in addition. Some examples of what to provide here:
* What command did you enter?
* What did you click on?
* What settings did you change?
* Provide a step-by-step list of what you tried.
* What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
* Did something happen?
* Did something not happen?
* Are there any error messages showing?
* Screenshots can help us see what you are seeing
* The Overseerr logs show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
- What did you try to do? When you describe what you did to reach the state you are in, we may notice something you did differently from the official instructions, or something required by your unique setup. The following are questions that should be answered in your request:
- What command did you enter?
- What did you click on?
- What settings did you change?
- Provide a step-by-step list of what you tried.
- What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
- Did something happen?
- Did something not happen?
- Are there any error messages showing?
- Provide screenshots to help us see what you are seeing.
- Share your Overseerr logs, which show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
## How can I share my logs?
First you will need to gather your logs from the install directory.
1. Collect the log file from `<Overseeerr-install-directory>/logs/overseerr.log`
2. Open the log file and **upload the text** by going to [gist.github.com](https://gist.github.com/) and creating a new secret Gist of the contents.
3. **Share the link** with support in [Discord](https://discord.gg/PkCWJSeCk7) by copying the URL of the page.
1. Locate the log file at `<Overseeerr-install-directory>/logs/overseerr.log`
2. Open the log file and **copy its contents** into a [**secret gist** on GitHub](https://gist.github.com/). If you upload your logs elsewhere, we may ask you to share them again via GitHub Gist.
3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/PkCWJSeCk7).

View File

@@ -1,4 +1,4 @@
# Frequently Asked Questions
# Frequently Asked Questions (FAQ)
{% hint style="info" %}
If you can't find a solution here, please ask on [Discord](https://discord.gg/PkCWJSeCk7). Please do not post questions on the GitHub issues tracker.
@@ -6,9 +6,9 @@ If you can't find a solution here, please ask on [Discord](https://discord.gg/Pk
## General
### I receive 409 or 400 errors when requesting a movie or tv show!
### I receive 409 or 400 errors when requesting a movie or TV series!
**A:** Verify your are running radarr and sonarr v3. Overseerr was developed for v3 and is not currently backward compatible.
**A:** Verify you are running Radarr and Sonarr v3. Overseerr was developed for v3 and is not currently backwards-compatible with previous versions.
### How do I keep Overseerr up-to-date?
@@ -24,15 +24,15 @@ The most secure method, but also the most inconvenient, is to set up a VPN tunne
### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations?
**A:** You sure can! We are using Weblate for translations! Check it out [here](https://hosted.weblate.org/engage/overseerr/). If your language is not listed please open an [enhancement request in issues](https://github.com/sct/overseerr/issues/new/choose).
**A:** You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
### Where can I find the changelog?
**A:** You can find the changelog in the **Settings -&gt; About** page in your instance. You can also find it on github [here](https://github.com/sct/overseerr/releases).
**A:** You can find the changelog in the **Settings &rarr; About** page in your Overseerr instance. You can also find it on [GitHub](https://github.com/sct/overseerr/releases).
### Can I make 4K requests?
**A:** 4K requests are not supported just yet but they will be supported in the future!
**A:** Yes! When adding your 4K Sonarr/Radarr server in **Settings &rarr; Services**, tick the `4K Server` checkbox. You also need to tick the `Default Server` checkbox if it is the default server you would like to use for 4K content requests. (To enable 4K requests, there need to be default Sonarr/Radarr servers for both 4K content **and** non-4K content.)
### Some media is missing from Overseerr that I know is in Plex!
@@ -40,7 +40,7 @@ The most secure method, but also the most inconvenient, is to set up a VPN tunne
**Troubleshooting Steps:**
Check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item.
First, check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item.
1. Verify that you are using one of the agents mentioned above.
2. Refresh the metadata for just that item.
@@ -48,48 +48,50 @@ Check the Overseerr logs for media items that are missing. The logs will contain
4. If the item is now seen by Overseerr then repeat step 2 for each missing item. If you have a large amount of items missing then a full metadata refresh is recommended for that library.
5. Run a full scan on Overseerr after refreshing all unmatched items.
Perform these steps to verify the media item has a guid Overseerr can match.
You can also perform the following to verify the media item has a GUID Overseerr can match:
1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**.
2. Verify that the media item has the same format of one of the examples below.
2. Verify that the media item's GUID follows one of the below formats:
**Examples:**
1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"`
2. New Plex Movie agent `<Guid id="tmdb://464052"/>`
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"`
2. New Plex Movie agent `<Guid id="tmdb://464052"/>`
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
### TV series requests are failing after I updated Overseerr!
**A:** Language profile support for Sonarr was added in [#860](https://github.com/sct/overseerr/pull/860), along with a new "Language Profile" required setting. If your TV series requests are failing, please make sure that you have a default language profile configured for each of your Sonarr servers in **Settings &rarr; Services**.
### Where can I find the logs?
**A:** The logs are located at `<Overseeerr-install-directory>/logs/overseerr.log`
## User Management
## User management
### Why can't I see all my Plex users?
**A:** Navigate to your **User List** in Overseerr and click **Import Users from Plex** button. Don't forget to check the default user permissions in the **Settings -&gt; General Settings** page beforehand.
**A:** Navigate to your **User List** in Overseerr and click **Import Users from Plex** button. Don't forget to check the default user permissions in the **Settings &rarr; General Settings** page beforehand.
### Can I create local users in Overseerr?
**A:** Not at this time. But it is a planned feature!
**A:** Head to the **Users** page and hit **Create Local User**. Keep in mind that local user accounts need a valid email address.
### Is is possible to set user roles in Overseerr?
**A:** Unfortunately, this is not possible yet. It is planned!
**A:** User roles can be set for each user on the **Users** page. The list of assignable permissions is one that is still growing, so if you have any suggestions, [make a feature request](https://github.com/sct/overseerr/issues/new/choose) on GitHub.
## Requests
### I approved a requested movie and radarr didn't search for it!
### I approved a requested movie and Radarr didn't search for it!
**A:** Check your minimum availability in radarr. If an added item does not meet the minimum availability, no search will be performed. Also verify that radarr did not search for it by checking the radarr logs. Lastly, verify the item was not already being monitored by radarr. Currently there is no state sync with radarr.
**A:** Check the minimum availability setting in your Radarr server. If a movie does not meet the minimum availability requirement, no search will be performed. Also verify that Radarr did not perform a search, by checking the Radarr logs. Lastly, verify that the item was not already being monitored by Radarr prior to approving the request.
### Help! My request still shows "requested" even though it's in Plex!?!
### Help! My request still shows "requested" even though it is in Plex!
**A:** See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
## Notifications
### I am getting "Username and Password not accepted" when sending email notifications to gmail!
### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail!
**A:** If you have 2-Step Verification enabled on your account you will need to create an app password. More details can be found [here](https://support.google.com/mail/answer/185833).
**A:** If you have 2-Step Verification enabled on your account, you will need to create an [app password](https://support.google.com/mail/answer/185833).

View File

@@ -4,25 +4,26 @@ Overseerr already supports a good number of notification agents, such as **Disco
## Currently Supported Notification Agents
- Email
- Discord
- Email
- Pushbullet
- Pushover
- Slack
- Telegram
- Pushover
- [Webhooks](./webhooks.md)
## Setting Up Notifications
Configuring your notifications is _very simple_. First, you will need to visit the **Settings** page and click **Notifications** in the menu. This will present you with all of the currently available notification agents. Click on each one individually to configure them.
You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive any notifications!
You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive notifications!
Some agents may have specific configuration gotchas that will be covered in each notification agents documentation page.
Some agents may have specific configuration "gotchas" covered in their documentation pages.
{% hint style="danger" %}
Currently, you will **not receive notifications** for any auto-approved requests. However, you will still receive a notification when the media becomes available.
You will **not receive notifications** for any automatically approved requests unless the "Enable Notifications for Automatic Approvals" setting is enabled.
{% endhint %}
## Requesting new agents
## Requesting New Notification Agents
If we do not currently support a notification agent you would like, feel free to request it on our [GitHub Issues](https://github.com/sct/overseerr/issues). Make sure to search first to see if someone else already requested it!
If we do not currently support a notification agent you would like, feel free to request it on [GitHub](https://github.com/sct/overseerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent!

View File

@@ -6,7 +6,7 @@ Webhooks let you post a custom JSON payload to any endpoint you like. You can al
The following configuration options are available:
### Webhook URL (Required)
### Webhook URL (required)
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
@@ -14,28 +14,28 @@ The URL you would like to post notifications to. Your JSON will be sent as the b
Custom authorization header. Anything entered for this will be sent as an `Authorization` header.
### Custom JSON Payload (Required)
### JSON Payload (required)
Design your JSON payload as you see fit. JSON is validated before you can save or test. Overseerr provides several [template variables](./webhooks.md#template-variables) for use in the payload which will be replaced with actual values when the notifications are sent.
You can always reset back to the default custom payload setting by clicking the `Reset to Default JSON Payload` button under the editor.
Customize the JSON payload to suit your needs. Overseerr provides several [template variables](./webhooks.md#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
## Template Variables
### Main
### General
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`)
- `{{subject}}` The notification subject message. (For request notifications, this is the media title)
- `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis)
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster)
### Notify User
### User
These variables are usually the target user of the notification.
- `{{notifyuser_username}}` Target user's username.
- `{{notifyuser_email}}` Target user's email.
- `{{notifyuser_avatar}}` Target user's avatar.
- `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set).
- `{{notifyuser_settings_telegramChatId}}` Target user's telegram Chat ID (if one is set).
### Media
@@ -45,12 +45,13 @@ These variables are only included in media related notifications, such as reques
- `{{media_tmdbid}}` Media's TMDb ID.
- `{{media_imdbid}}` Media's IMDb ID.
- `{{media_tvdbid}}` Media's TVDB ID.
- `{{media_status}}` Media's availability status. (Ex. `AVAILABLE` or `PENDING`)
- `{{media_status4k}}` Media's 4K availability status. (Ex. `AVAILABLE` or `PENDING`)
- `{{media_status}}` Media's availability status (e.g., `AVAILABLE` or `PENDING`).
- `{{media_status4k}}` Media's 4K availability status (e.g., `AVAILABLE` or `PENDING`).
### Special Key Variables
### Special
These variables must be used as a key in the JSON Payload. (Ex, `"{{extra}}": []`).
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
- `{{extra}}` This will override the value of the property to be the pre-formatted "extra" array that can come along with certain notifications. Using this variable is _not required_.
- `{{media}}` This will override the value of the property to `null` if there is no media object passed along with the notification.
- `{{request}}` This object will be `null` if there is no relevant request object for the notification.
- `{{media}}` This object will be `null` if there is no relevant media object for the notification.
- `{{extra}}` This object will contain the "extra" array of additional data for certain notifications.

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "overseerr",
"version": "1.19.1",
"version": "1.20.1",
"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",
@@ -9,7 +9,7 @@
"build": "yarn build:next && yarn build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
"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}'",
"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",
@@ -17,61 +17,62 @@
},
"license": "MIT",
"dependencies": {
"@headlessui/react": "^0.2.0-da179ca",
"@headlessui/react": "^0.3.1",
"@supercharge/request-ip": "^1.1.2",
"@svgr/webpack": "^5.5.0",
"ace-builds": "^1.4.12",
"axios": "^0.21.1",
"bcrypt": "^5.0.0",
"bcrypt": "^5.0.1",
"body-parser": "^1.19.0",
"bowser": "^2.11.0",
"connect-typeorm": "^1.1.4",
"cookie-parser": "^1.4.5",
"country-flag-icons": "^1.2.9",
"csurf": "^1.11.0",
"email-templates": "^8.0.3",
"express": "^4.17.1",
"express-openapi-validator": "^4.10.11",
"express-openapi-validator": "^4.12.4",
"express-session": "^1.17.1",
"formik": "^2.2.6",
"gravatar-url": "^3.1.0",
"intl": "^1.2.5",
"lodash": "^4.17.20",
"lodash": "^4.17.21",
"next": "10.0.3",
"node-cache": "^5.1.2",
"node-schedule": "^2.0.0",
"nodemailer": "^6.4.17",
"nodemailer": "^6.5.0",
"nookies": "^2.5.2",
"plex-api": "^5.3.1",
"pug": "^3.0.0",
"pug": "^3.0.2",
"react": "17.0.1",
"react-ace": "^9.3.0",
"react-animate-height": "^2.0.23",
"react-dom": "17.0.1",
"react-intersection-observer": "^8.31.0",
"react-intl": "^5.12.0",
"react-intl": "^5.13.2",
"react-markdown": "^5.0.3",
"react-spring": "^8.0.27",
"react-toast-notifications": "^2.4.0",
"react-toast-notifications": "^2.4.3",
"react-transition-group": "^4.4.1",
"react-truncate-markup": "^5.1.0",
"react-use-clipboard": "1.0.7",
"reflect-metadata": "^0.1.13",
"secure-random-password": "^0.2.2",
"sqlite3": "^5.0.0",
"sqlite3": "^5.0.2",
"swagger-ui-express": "^4.1.6",
"swr": "^0.4.1",
"typeorm": "^0.2.30",
"swr": "^0.5.1",
"typeorm": "^0.2.31",
"uuid": "^8.3.2",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.0",
"winston-daily-rotate-file": "^4.5.1",
"xml2js": "^0.4.23",
"yamljs": "^0.3.0",
"yup": "^0.32.8"
"yup": "^0.32.9"
},
"devDependencies": {
"@babel/cli": "^7.12.13",
"@commitlint/cli": "^11.0.0",
"@commitlint/config-conventional": "^11.0.0",
"@babel/cli": "^7.13.10",
"@commitlint/cli": "^12.0.1",
"@commitlint/config-conventional": "^12.0.1",
"@semantic-release/changelog": "^5.0.1",
"@semantic-release/commit-analyzer": "^8.0.1",
"@semantic-release/exec": "^5.0.0",
@@ -82,51 +83,53 @@
"@types/bcrypt": "^3.0.0",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/country-flag-icons": "^1.2.0",
"@types/csurf": "^1.11.0",
"@types/email-templates": "^8.0.1",
"@types/email-templates": "^8.0.2",
"@types/express": "^4.17.11",
"@types/express-session": "^1.17.3",
"@types/lodash": "^4.14.168",
"@types/node": "^14.14.24",
"@types/node": "^14.14.33",
"@types/node-schedule": "^1.3.1",
"@types/nodemailer": "^6.4.0",
"@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.2",
"@types/react-toast-notifications": "^2.4.0",
"@types/react-transition-group": "^4.4.0",
"@types/react-transition-group": "^4.4.1",
"@types/secure-random-password": "^0.2.0",
"@types/swagger-ui-express": "^4.1.2",
"@types/uuid": "^8.3.0",
"@types/xml2js": "^0.4.8",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.11",
"@typescript-eslint/eslint-plugin": "^4.14.2",
"@typescript-eslint/parser": "^4.14.2",
"autoprefixer": "^10.2.4",
"@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0",
"autoprefixer": "^10.2.5",
"babel-plugin-react-intl": "^8.2.25",
"babel-plugin-react-intl-auto": "^3.3.0",
"commitizen": "^4.2.3",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.19.0",
"eslint": "^7.21.0",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-formatjs": "^2.12.0",
"eslint-plugin-formatjs": "^2.12.7",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0",
"extract-react-intl-messages": "^4.1.1",
"husky": "^4.3.8",
"lint-staged": "^10.5.3",
"husky": "4.3.8",
"lint-staged": "^10.5.4",
"nodemon": "^2.0.7",
"postcss": "^8.2.4",
"postcss": "^8.2.8",
"postcss-preset-env": "^6.7.0",
"prettier": "^2.2.1",
"semantic-release": "^17.3.7",
"semantic-release": "^17.4.1",
"semantic-release-docker": "^2.2.0",
"semantic-release-docker-buildx": "^1.0.1",
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
"ts-node": "^9.1.1",
"typescript": "^4.1.3"
"typescript": "^4.2.3"
},
"resolutions": {
"sqlite3/node-gyp": "^5.1.0"
@@ -166,48 +169,32 @@
"release": {
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
"@semantic-release/npm",
[
"@semantic-release/git",
{
"assets": [
"package.json",
"CHANGELOG.md"
],
"message": "chore(release): ${nextRelease.version}"
}
],
[
"@semantic-release/exec",
{
"prepareCmd": "docker build --build-arg COMMIT_TAG=$GITHUB_SHA -t sctx/overseerr ."
}
],
"semantic-release-docker",
[
"@semantic-release/github",
{
"addReleases": "bottom"
}
]
"semantic-release-docker-buildx"
],
"branches": [
"master"
"master",
{
"name": "arm-test",
"prerelease": true
}
],
"npmPublish": false,
"publish": [
{
"path": "semantic-release-docker",
"name": "sctx/overseerr"
},
"@semantic-release/github"
"path": "semantic-release-docker-buildx",
"buildArgs": {
"COMMIT_TAG": "$GITHUB_SHA"
},
"imageNames": [
"sctx/overseerr",
"ghcr.io/sct/overseerr"
],
"platforms": [
"linux/amd64",
"linux/arm64",
"linux/arm/v7"
]
}
]
}
}

View File

@@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1024 1025" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="1024" height="1024"/><clipPath id="b"><use clip-rule="evenodd" xlink:href="#a"/></clipPath></defs><g clip-path="url(#b)"><use fill="#24292E" fill-opacity="0" xlink:href="#a"/><g transform="translate(70 18)"><path d="m105.3 156.12l7.522 719.97c-60.173 7.579-105.3-22.736-105.3-83.364l-7.5216-598.71c0-189.46 173-234.94 278.3-159.15l534.03 310.72c75.216 53.05 90.259 151.57 52.651 219.78-7.522-53.05-30.086-83.365-75.216-113.68l-601.73-341.04c-45.13-30.315-82.738-22.736-82.738 45.471z" fill="#fff"/><path transform="translate(60.173 535.05)" d="m0 378.93c45.13 15.158 90.259 7.579 127.87-15.157l616.77-363.77c37.607 53.05 30.086 106.1-15.044 136.42l-518.99 303.14c-75.216 37.893-173 0-210.6-60.629z" fill="#fff"/><path transform="translate(240.69 284.95)" d="M0 416.822L368.558 204.622L7.52159 0L0 416.822Z" fill="#FFC230"/></g></g></svg>
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1000 1115.2" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xlink="http://www.w3.org/1999/xlink"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><defs><path id="a" d="m0 0h1024v1024h-1024z"/></defs><use transform="rotate(.704 1914.4 -5491.6)" width="100%" height="100%" fill="#ffffff" fill-opacity="0" xlink:href="#a"/><g transform="matrix(1.1348 0 0 1.1348 -.0011348 -.013738)"><path d="m105.76 154.15-1.263 714.59c-60.261 6.782-105.02-23.858-104.28-84.025l-0.216-594.25c2.312-188.02 175.85-231.02 280.22-154.52l530.2 314.93c74.563 53.572 88.403 151.53 49.965 218.76-6.873-52.739-29.067-83.101-73.823-113.74l-597.52-345.84c-44.756-30.639-82.453-23.58-83.286 44.109zm-54.377 751.54c44.941 15.597 90.16 8.63 128.04-13.47l621.16-353.43c36.958 53.109 28.79 105.66-16.706 135.19l-522.65 294.46c-75.673 36.68-172.98-2.127-209.85-62.757z" fill="#fff"/><path d="m240.52 702.59 365.02-216.68-364.35-197.07z" fill="#ffc230"/></g></svg>

Before

Width:  |  Height:  |  Size: 1023 B

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -212,7 +212,7 @@ class PlexTvAPI {
return parsedXml;
}
public async checkUserAccess(authUser: PlexUser): Promise<boolean> {
public async checkUserAccess(userId: number): Promise<boolean> {
const settings = getSettings();
try {
@@ -224,11 +224,11 @@ class PlexTvAPI {
const users = friends.MediaContainer.User;
const user = users.find((u) => Number(u.$.id) === authUser.id);
const user = users.find((u) => Number(u.$.id) === userId);
if (!user) {
throw new Error(
'This user does not exist on the main plex accounts shared list'
"This user does not exist on the main Plex account's shared list"
);
}

View File

@@ -242,7 +242,7 @@ class RadarrAPI extends ExternalAPI {
public getProfiles = async (): Promise<RadarrProfile[]> => {
try {
const data = await this.getRolling<RadarrProfile[]>(
`/profile`,
`/qualityProfile`,
undefined,
3600
);

View File

@@ -112,6 +112,7 @@ interface AddSeriesOptions {
tvdbid: number;
title: string;
profileId: number;
languageProfileId?: number;
seasons: number[];
seasonFolder: boolean;
rootFolderPath: string;
@@ -120,6 +121,11 @@ interface AddSeriesOptions {
searchNow?: boolean;
}
export interface LanguageProfile {
id: number;
name: string;
}
class SonarrAPI extends ExternalAPI {
static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
@@ -201,11 +207,6 @@ class SonarrAPI extends ExternalAPI {
if (series.id) {
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
series.addOptions = {
ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: options.searchNow,
};
const newSeriesResponse = await this.axios.put<SonarrSeries>(
'/series',
series
@@ -219,6 +220,9 @@ class SonarrAPI extends ExternalAPI {
label: 'Sonarr',
movie: newSeriesResponse.data,
});
if (options.searchNow) {
this.searchSeries(newSeriesResponse.data.id);
}
} else {
logger.error('Failed to update series in Sonarr', {
label: 'Sonarr',
@@ -235,7 +239,8 @@ class SonarrAPI extends ExternalAPI {
{
tvdbId: options.tvdbid,
title: options.title,
profileId: options.profileId,
qualityProfileId: options.profileId,
languageProfileId: options.languageProfileId,
seasons: this.buildSeasonList(
options.seasons,
series.seasons.map((season) => ({
@@ -284,7 +289,7 @@ class SonarrAPI extends ExternalAPI {
public async getProfiles(): Promise<SonarrProfile[]> {
try {
const data = await this.getRolling<SonarrProfile[]>(
'/profile',
'/qualityProfile',
undefined,
3600
);
@@ -321,6 +326,55 @@ class SonarrAPI extends ExternalAPI {
}
}
public async getLanguageProfiles(): Promise<LanguageProfile[]> {
try {
const data = await this.getRolling<LanguageProfile[]>(
'/languageprofile',
undefined,
3600
);
return data;
} catch (e) {
logger.error(
'Something went wrong while retrieving Sonarr language profiles.',
{
label: 'Sonarr API',
message: e.message,
}
);
throw new Error('Failed to get language profiles');
}
}
public async searchSeries(seriesId: number): Promise<void> {
logger.info('Executing series search command', {
label: 'Sonarr API',
seriesId,
});
await this.runCommand('SeriesSearch', { seriesId });
}
private async runCommand(
commandName: string,
options: Record<string, unknown>
): Promise<void> {
try {
await this.axios.post(`/command`, {
name: commandName,
...options,
});
} catch (e) {
logger.error('Something went wrong attempting to run a Sonarr command.', {
label: 'Sonarr API',
message: e.message,
});
throw new Error('Failed to run Sonarr command.');
}
}
private buildSeasonList(
seasons: number[],
existingSeasons?: SonarrSeason[]

View File

@@ -1,17 +1,24 @@
import { sortBy } from 'lodash';
import cacheManager from '../../lib/cache';
import ExternalAPI from '../externalapi';
import {
TmdbCollection,
TmdbExternalIdResponse,
TmdbGenre,
TmdbGenresResult,
TmdbLanguage,
TmdbMovieDetails,
TmdbNetwork,
TmdbPersonCombinedCredits,
TmdbPersonDetail,
TmdbRegion,
TmdbSearchMovieResponse,
TmdbSearchMultiResponse,
TmdbSearchTvResponse,
TmdbSeasonWithEpisodes,
TmdbTvDetails,
TmdbUpcomingMoviesResponse,
TmdbProductionCompany,
} from './interfaces';
interface SearchOptions {
@@ -25,6 +32,11 @@ interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
language?: string;
primaryReleaseDateGte?: string;
primaryReleaseDateLte?: string;
originalLanguage?: string;
genre?: number;
studio?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
@@ -45,6 +57,12 @@ interface DiscoverMovieOptions {
interface DiscoverTvOptions {
page?: number;
language?: string;
firstAirDateGte?: string;
firstAirDateLte?: string;
includeEmptyReleaseDate?: boolean;
originalLanguage?: string;
genre?: number;
network?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
@@ -57,7 +75,12 @@ interface DiscoverTvOptions {
}
class TheMovieDb extends ExternalAPI {
constructor() {
private region?: string;
private originalLanguage?: string;
constructor({
region,
originalLanguage,
}: { region?: string; originalLanguage?: string } = {}) {
super(
'https://api.themoviedb.org/3',
{
@@ -67,6 +90,8 @@ class TheMovieDb extends ExternalAPI {
nodeCache: cacheManager.getCache('tmdb').data,
}
);
this.region = region;
this.originalLanguage = originalLanguage;
}
public searchMulti = async ({
@@ -105,7 +130,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`);
}
};
@@ -127,7 +152,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch person combined credits: ${e.message}`
`[TMDb] Failed to fetch person combined credits: ${e.message}`
);
}
};
@@ -145,7 +170,7 @@ class TheMovieDb extends ExternalAPI {
{
params: {
language,
append_to_response: 'credits,external_ids,videos',
append_to_response: 'credits,external_ids,videos,release_dates',
},
},
43200
@@ -153,7 +178,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`);
}
};
@@ -171,7 +196,7 @@ class TheMovieDb extends ExternalAPI {
params: {
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos',
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings',
},
},
43200
@@ -179,7 +204,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
}
};
@@ -205,7 +230,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
}
};
@@ -231,7 +256,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
}
@@ -257,7 +282,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
}
@@ -283,7 +308,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`);
}
}
@@ -310,7 +335,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch tv recommendations: ${e.message}`
`[TMDb] Failed to fetch TV recommendations: ${e.message}`
);
}
}
@@ -334,7 +359,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`);
}
}
@@ -343,6 +368,11 @@ class TheMovieDb extends ExternalAPI {
page = 1,
includeAdult = false,
language = 'en',
primaryReleaseDateGte,
primaryReleaseDateLte,
originalLanguage,
genre,
studio,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
@@ -351,19 +381,31 @@ class TheMovieDb extends ExternalAPI {
page,
include_adult: includeAdult,
language,
region: this.region,
with_original_language: originalLanguage ?? this.originalLanguage,
'primary_release_date.gte': primaryReleaseDateGte,
'primary_release_date.lte': primaryReleaseDateLte,
with_genres: genre,
with_companies: studio,
},
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
};
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
language = 'en',
language = 'en-US',
firstAirDateGte,
firstAirDateLte,
includeEmptyReleaseDate = false,
originalLanguage,
genre,
network,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
@@ -371,12 +413,19 @@ class TheMovieDb extends ExternalAPI {
sort_by: sortBy,
page,
language,
region: this.region,
'first_air_date.gte': firstAirDateGte,
'first_air_date.lte': firstAirDateLte,
with_original_language: originalLanguage ?? this.originalLanguage,
include_null_first_air_dates: includeEmptyReleaseDate,
with_genres: genre,
with_networks: network,
},
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`);
}
};
@@ -394,13 +443,15 @@ class TheMovieDb extends ExternalAPI {
params: {
page,
language,
region: this.region,
originalLanguage: this.originalLanguage,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`);
}
};
@@ -420,13 +471,14 @@ class TheMovieDb extends ExternalAPI {
params: {
page,
language,
region: this.region,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
@@ -449,7 +501,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
@@ -472,7 +524,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
@@ -504,7 +556,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`);
}
}
@@ -531,11 +583,11 @@ class TheMovieDb extends ExternalAPI {
}
throw new Error(
'[TMDB] Failed to find a title with the provided IMDB id'
'[TMDb] Failed to find a title with the provided IMDB id'
);
} catch (e) {
throw new Error(
`[TMDB] Failed to get movie by external imdb ID: ${e.message}`
`[TMDb] Failed to get movie by external imdb ID: ${e.message}`
);
}
}
@@ -563,11 +615,11 @@ class TheMovieDb extends ExternalAPI {
}
throw new Error(
`[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}`
`[TMDb] Failed to find a TV show with the provided TVDB ID: ${tvdbId}`
);
} catch (e) {
throw new Error(
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
`[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}`
);
}
}
@@ -591,7 +643,109 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`);
}
}
public async getRegions(): Promise<TmdbRegion[]> {
try {
const data = await this.get<TmdbRegion[]>(
'/configuration/countries',
{},
86400 // 24 hours
);
const regions = sortBy(data, 'english_name');
return regions;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`);
}
}
public async getLanguages(): Promise<TmdbLanguage[]> {
try {
const data = await this.get<TmdbLanguage[]>(
'/configuration/languages',
{},
86400 // 24 hours
);
const languages = sortBy(data, 'english_name');
return languages;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`);
}
}
public async getStudio(studioId: number): Promise<TmdbProductionCompany> {
try {
const data = await this.get<TmdbProductionCompany>(
`/company/${studioId}`
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`);
}
}
public async getNetwork(networkId: number): Promise<TmdbNetwork> {
try {
const data = await this.get<TmdbNetwork>(`/network/${networkId}`);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`);
}
}
public async getMovieGenres({
language = 'en',
}: {
language?: string;
} = {}): Promise<TmdbGenre[]> {
try {
const data = await this.get<TmdbGenresResult>(
'/genre/movie/list',
{
params: {
language,
},
},
86400 // 24 hours
);
const movieGenres = sortBy(data.genres, 'name');
return movieGenres;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`);
}
}
public async getTvGenres({
language = 'en',
}: {
language?: string;
} = {}): Promise<TmdbGenre[]> {
try {
const data = await this.get<TmdbGenresResult>(
'/genre/tv/list',
{
params: {
language,
},
},
86400 // 24 hours
);
const tvGenres = sortBy(data.genres, 'name');
return tvGenres;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`);
}
}
}

View File

@@ -109,6 +109,16 @@ export interface TmdbExternalIds {
twitter_id?: string;
}
export interface TmdbProductionCompany {
id: number;
logo_path?: string;
name: string;
origin_country: string;
homepage?: string;
headquarters?: string;
description?: string;
}
export interface TmdbMovieDetails {
id: number;
imdb_id?: string;
@@ -125,17 +135,13 @@ export interface TmdbMovieDetails {
original_title: string;
overview?: string;
popularity: number;
production_companies: {
id: number;
name: string;
logo_path?: string;
origin_country: string;
}[];
production_companies: TmdbProductionCompany[];
production_countries: {
iso_3166_1: string;
name: string;
}[];
release_date: string;
release_dates: TmdbMovieReleaseResult;
revenue: number;
runtime?: number;
spoken_languages: {
@@ -205,6 +211,7 @@ export interface TmdbTvSeasonResult {
export interface TmdbTvDetails {
id: number;
backdrop_path?: string;
content_ratings: TmdbTvRatingResult;
created_by: {
id: number;
credit_id: string;
@@ -225,12 +232,7 @@ export interface TmdbTvDetails {
last_episode_to_air?: TmdbTvEpisodeResult;
name: string;
next_episode_to_air?: TmdbTvEpisodeResult;
networks: {
id: number;
name: string;
logo_path: string;
origin_country: string;
}[];
networks: TmdbNetwork[];
number_of_episodes: number;
number_of_seasons: number;
origin_country: string[];
@@ -272,6 +274,29 @@ export interface TmdbVideoResult {
results: TmdbVideo[];
}
export interface TmdbTvRatingResult {
results: TmdbRating[];
}
export interface TmdbRating {
iso_3166_1: string;
rating: string;
}
export interface TmdbMovieReleaseResult {
results: TmdbRelease[];
}
export interface TmdbRelease extends TmdbRating {
release_dates: {
certification: string;
iso_639_1?: string;
note?: string;
release_date: string;
type: number;
}[];
}
export interface TmdbKeyword {
id: number;
name: string;
@@ -316,6 +341,7 @@ export interface TmdbPersonCredit {
adult: boolean;
release_date: string;
}
export interface TmdbPersonCreditCast extends TmdbPersonCredit {
character: string;
}
@@ -344,3 +370,32 @@ export interface TmdbCollection {
backdrop_path?: string;
parts: TmdbMovieResult[];
}
export interface TmdbRegion {
iso_3166_1: string;
english_name: string;
}
export interface TmdbLanguage {
iso_639_1: string;
english_name: string;
name: string;
}
export interface TmdbGenresResult {
genres: TmdbGenre[];
}
export interface TmdbGenre {
id: number;
name: string;
}
export interface TmdbNetwork {
id: number;
name: string;
headquarters?: string;
homepage?: string;
logo_path?: string;
origin_country?: string;
}

View File

@@ -2,7 +2,6 @@ export enum MediaRequestStatus {
PENDING = 1,
APPROVED,
DECLINED,
AVAILABLE,
}
export enum MediaType {

View File

@@ -78,6 +78,9 @@ export class MediaRequest {
@Column({ nullable: true })
public rootFolder: string;
@Column({ nullable: true })
public languageProfileId: number;
constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}
@@ -108,6 +111,7 @@ export class MediaRequest {
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
media,
request: this,
});
}
@@ -127,6 +131,7 @@ export class MediaRequest {
.join(', '),
},
],
request: this,
});
}
}
@@ -139,7 +144,7 @@ export class MediaRequest {
* auto approved content
*/
@AfterUpdate()
public async notifyApprovedOrDeclined(): Promise<void> {
public async notifyApprovedOrDeclined(autoApproved = false): Promise<void> {
if (
this.status === MediaRequestStatus.APPROVED ||
this.status === MediaRequestStatus.DECLINED
@@ -166,7 +171,9 @@ export class MediaRequest {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
notificationManager.sendNotification(
this.status === MediaRequestStatus.APPROVED
? Notification.MEDIA_APPROVED
? autoApproved
? Notification.MEDIA_AUTO_APPROVED
: Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED,
{
subject: movie.title,
@@ -174,13 +181,16 @@ export class MediaRequest {
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
media,
request: this,
}
);
} else if (this.media.mediaType === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
notificationManager.sendNotification(
this.status === MediaRequestStatus.APPROVED
? Notification.MEDIA_APPROVED
? autoApproved
? Notification.MEDIA_AUTO_APPROVED
: Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED,
{
subject: tv.name,
@@ -196,6 +206,7 @@ export class MediaRequest {
.join(', '),
},
],
request: this,
}
);
}
@@ -204,13 +215,8 @@ export class MediaRequest {
@AfterInsert()
public async autoapprovalNotification(): Promise<void> {
const settings = getSettings().notifications;
if (
settings.autoapprovalEnabled &&
this.status === MediaRequestStatus.APPROVED
) {
this.notifyApprovedOrDeclined();
if (this.status === MediaRequestStatus.APPROVED) {
this.notifyApprovedOrDeclined(true);
}
}
@@ -380,9 +386,7 @@ export class MediaRequest {
const tmdb = new TheMovieDb();
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
});
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
@@ -451,6 +455,7 @@ export class MediaRequest {
notifyUser: admin,
media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: this,
});
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
@@ -527,15 +532,15 @@ export class MediaRequest {
const tmdb = new TheMovieDb();
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!tvdbId) {
this.handleRemoveParentUpdate();
const requestRepository = getRepository(MediaRequest);
await mediaRepository.remove(media);
await requestRepository.remove(this);
throw new Error('Series was missing tvdb id');
}
@@ -559,6 +564,11 @@ export class MediaRequest {
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId;
let languageProfile =
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
? sonarrSettings.activeAnimeLanguageProfileId
: sonarrSettings.activeLanguageProfileId;
if (
this.rootFolder &&
this.rootFolder !== '' &&
@@ -577,10 +587,24 @@ export class MediaRequest {
});
}
if (
this.languageProfileId &&
this.languageProfileId !== languageProfile
) {
languageProfile = this.languageProfileId;
logger.info(
`Request has an override Language Profile: ${languageProfile}`,
{
label: 'Media Request',
}
);
}
// Run this asynchronously so we don't wait for it on the UI side
sonarr
.addSeries({
profileId: qualityProfile,
languageProfileId: languageProfile,
rootFolderPath: rootFolder,
title: series.name,
tvdbid: tvdbId,
@@ -635,6 +659,7 @@ export class MediaRequest {
.join(', '),
},
],
request: this,
});
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });

View File

@@ -7,6 +7,7 @@ import {
OneToMany,
RelationCount,
AfterLoad,
OneToOne,
} from 'typeorm';
import {
Permission,
@@ -21,14 +22,19 @@ import logger from '../logger';
import { getSettings } from '../lib/settings';
import { default as generatePassword } from 'secure-random-password';
import { UserType } from '../constants/user';
import { v4 as uuid } from 'uuid';
import { UserSettings } from './UserSettings';
@Entity()
export class User {
public static filterMany(users: User[]): Partial<User>[] {
return users.map((u) => u.filter());
public static filterMany(
users: User[],
showFiltered?: boolean
): Partial<User>[] {
return users.map((u) => u.filter(showFiltered));
}
static readonly filteredFields: string[] = ['plexToken', 'password'];
static readonly filteredFields: string[] = ['email'];
public displayName: string;
@@ -47,6 +53,12 @@ export class User {
@Column({ nullable: true, select: false })
public password?: string;
@Column({ nullable: true, select: false })
public resetPasswordGuid?: string;
@Column({ type: 'date', nullable: true })
public recoveryLinkExpirationDate?: Date | null;
@Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType;
@@ -68,6 +80,13 @@ export class User {
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
public requests: MediaRequest[];
@OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true,
eager: true,
onDelete: 'CASCADE',
})
public settings?: UserSettings;
@CreateDateColumn()
public createdAt: Date;
@@ -78,11 +97,11 @@ export class User {
Object.assign(this, init);
}
public filter(): Partial<User> {
public filter(showFiltered?: boolean): Partial<User> {
const filtered: Partial<User> = Object.assign(
{},
...(Object.keys(this) as (keyof User)[])
.filter((k) => !User.filteredFields.includes(k))
.filter((k) => showFiltered || !User.filteredFields.includes(k))
.map((k) => ({ [k]: this[k] }))
);
@@ -97,11 +116,11 @@ export class User {
}
public passwordMatch(password: string): Promise<boolean> {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
if (this.password) {
resolve(bcrypt.compare(password, this.password));
} else {
return reject(false);
return resolve(false);
}
});
}
@@ -111,29 +130,66 @@ export class User {
this.password = hashedPassword;
}
public async resetPassword(): Promise<void> {
public async generatePassword(): Promise<void> {
const password = generatePassword.randomPassword({ length: 16 });
this.setPassword(password);
const applicationUrl = getSettings().main.applicationUrl;
const { applicationTitle, applicationUrl } = getSettings().main;
try {
logger.info(`Sending password email for ${this.email}`, {
label: 'User creation',
logger.info(`Sending generated password email for ${this.email}`, {
label: 'User Management',
});
const email = new PreparedEmail();
await email.send({
template: path.join(__dirname, '../templates/email/password'),
template: path.join(__dirname, '../templates/email/generatedpassword'),
message: {
to: this.email,
},
locals: {
password: password,
applicationUrl,
applicationTitle,
},
});
} catch (e) {
logger.error('Failed to send out password email', {
label: 'User creation',
logger.error('Failed to send out generated password email', {
label: 'User Management',
message: e.message,
});
}
}
public async resetPassword(): Promise<void> {
const guid = uuid();
this.resetPasswordGuid = guid;
// 24 hours into the future
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + 1);
this.recoveryLinkExpirationDate = targetDate;
const { applicationTitle, applicationUrl } = getSettings().main;
const resetPasswordLink = `${applicationUrl}/resetpassword/${guid}`;
try {
logger.info(`Sending reset password email for ${this.email}`, {
label: 'User Management',
});
const email = new PreparedEmail();
await email.send({
template: path.join(__dirname, '../templates/email/resetpassword'),
message: {
to: this.email,
},
locals: {
resetPasswordLink,
applicationUrl: resetPasswordLink,
applicationTitle,
},
});
} catch (e) {
logger.error('Failed to send out reset password email', {
label: 'User Management',
message: e.message,
});
}

View File

@@ -0,0 +1,40 @@
import {
Column,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from './User';
@Entity()
export class UserSettings {
constructor(init?: Partial<UserSettings>) {
Object.assign(this, init);
}
@PrimaryGeneratedColumn()
public id: number;
@OneToOne(() => User, (user) => user.settings, { onDelete: 'CASCADE' })
@JoinColumn()
public user: User;
@Column({ default: true })
public enableNotifications: boolean;
@Column({ nullable: true })
public discordId?: string;
@Column({ nullable: true })
public telegramChatId?: string;
@Column({ nullable: true })
public telegramSendSilently?: boolean;
@Column({ nullable: true })
public region?: string;
@Column({ nullable: true })
public originalLanguage?: string;
}

View File

@@ -24,6 +24,7 @@ import SlackAgent from './lib/notifications/agents/slack';
import PushoverAgent from './lib/notifications/agents/pushover';
import WebhookAgent from './lib/notifications/agents/webhook';
import { getClientIp } from '@supercharge/request-ip';
import PushbulletAgent from './lib/notifications/agents/pushbullet';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@@ -51,9 +52,10 @@ app
notificationManager.registerAgents([
new DiscordAgent(),
new EmailAgent(),
new PushbulletAgent(),
new PushoverAgent(),
new SlackAgent(),
new TelegramAgent(),
new PushoverAgent(),
new WebhookAgent(),
]);

View File

@@ -1,4 +1,5 @@
import { RadarrProfile, RadarrRootFolder } from '../../api/radarr';
import { LanguageProfile } from '../../api/sonarr';
export interface ServiceCommonServer {
id: number;
@@ -7,12 +8,15 @@ export interface ServiceCommonServer {
isDefault: boolean;
activeProfileId: number;
activeDirectory: string;
activeLanguageProfileId?: number;
activeAnimeProfileId?: number;
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
}
export interface ServiceCommonServerWithDetails {
server: ServiceCommonServer;
profiles: RadarrProfile[];
rootFolders: Partial<RadarrRootFolder>[];
languageProfiles?: LanguageProfile[];
}

View File

@@ -12,6 +12,8 @@ export interface PublicSettingsResponse {
localLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
region: string;
originalLanguage: string;
}
export interface CacheItem {

View File

@@ -0,0 +1,11 @@
import type { User } from '../../entity/User';
import { MediaRequest } from '../../entity/MediaRequest';
import { PaginatedResponse } from './common';
export interface UserResultsResponse extends PaginatedResponse {
results: User[];
}
export interface UserRequestsResponse extends PaginatedResponse {
results: MediaRequest[];
}

View File

@@ -0,0 +1,13 @@
export interface UserSettingsGeneralResponse {
username?: string;
region?: string;
originalLanguage?: string;
}
export interface UserSettingsNotificationsResponse {
enableNotifications: boolean;
telegramBotUsername?: string;
discordId?: string;
telegramChatId?: string;
telegramSendSilently?: boolean;
}

View File

@@ -1,896 +0,0 @@
import { getRepository } from 'typeorm';
import { User } from '../../entity/User';
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi';
import TheMovieDb from '../../api/themoviedb';
import {
TmdbMovieDetails,
TmdbTvDetails,
} from '../../api/themoviedb/interfaces';
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';
import { v4 as uuid } from 'uuid';
import animeList from '../../api/animelist';
import AsyncLock from '../../utils/asyncLock';
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 tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
// Hama agent uses ASS naming, see details here:
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
const HAMA_AGENT = 'com.plexapp.agents.hama';
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentLibrary: Library;
libraries: Library[];
}
class JobPlexSync {
private sessionId: string;
private tmdb: TheMovieDb;
private plexClient: PlexAPI;
private items: PlexLibraryItem[] = [];
private progress = 0;
private libraries: Library[];
private currentLibrary: Library;
private running = false;
private isRecentOnly = false;
private enable4kMovie = false;
private enable4kShow = false;
private asyncLock = new AsyncLock();
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
this.tmdb = new TheMovieDb();
this.isRecentOnly = isRecentOnly ?? false;
}
private async getExisting(tmdbId: number, mediaType: MediaType) {
const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType },
});
return existing;
}
private async processMovie(plexitem: PlexLibraryItem) {
const mediaRepository = getRepository(Media);
try {
if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
const newMedia = new Media();
if (!metadata.Guid) {
logger.debug('No Guid metadata for this title. Skipping', {
label: 'Plex Sync',
ratingKey: plexitem.ratingKey,
});
return;
}
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 (newMedia.imdbId && !newMedia.tmdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: newMedia.imdbId,
});
newMedia.tmdbId = tmdbMovie.id;
}
if (!newMedia.tmdbId) {
throw new Error('Unable to find TMDb ID');
}
const has4k = metadata.Media.some(
(media) => media.videoResolution === '4k'
);
const hasOtherResolution = metadata.Media.some(
(media) => media.videoResolution !== '4k'
);
await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
const existing = await this.getExisting(
newMedia.tmdbId,
MediaType.MOVIE
);
if (existing) {
let changedExisting = false;
if (
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
existing.status !== MediaStatus.AVAILABLE
) {
existing.status = MediaStatus.AVAILABLE;
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.status4k !== MediaStatus.AVAILABLE
) {
existing.status4k = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (!existing.mediaAddedAt && !changedExisting) {
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
changedExisting = true;
}
if (
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
existing.ratingKey !== plexitem.ratingKey
) {
existing.ratingKey = plexitem.ratingKey;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.ratingKey4k !== plexitem.ratingKey
) {
existing.ratingKey4k = plexitem.ratingKey;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Request for ${metadata.title} exists. New media types set to AVAILABLE`,
'info'
);
} else {
this.log(
`Title already exists and no new media types found ${metadata.title}`
);
}
} else {
newMedia.status =
hasOtherResolution || (!this.enable4kMovie && has4k)
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.status4k =
has4k && this.enable4kMovie
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000);
newMedia.ratingKey =
hasOtherResolution || (!this.enable4kMovie && has4k)
? plexitem.ratingKey
: undefined;
newMedia.ratingKey4k =
has4k && this.enable4kMovie ? plexitem.ratingKey : undefined;
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');
}
await this.processMovieWithId(plexitem, tmdbMovie, tmdbMovieId);
}
} catch (e) {
this.log(
`Failed to process Plex item. ratingKey: ${plexitem.ratingKey}`,
'error',
{
errorMessage: e.message,
plexitem,
}
);
}
}
private async processMovieWithId(
plexitem: PlexLibraryItem,
tmdbMovie: TmdbMovieDetails | undefined,
tmdbMovieId: number
) {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbMovieId, async () => {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE);
const has4k = metadata.Media.some(
(media) => media.videoResolution === '4k'
);
const hasOtherResolution = metadata.Media.some(
(media) => media.videoResolution !== '4k'
);
if (existing) {
let changedExisting = false;
if (
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
existing.status !== MediaStatus.AVAILABLE
) {
existing.status = MediaStatus.AVAILABLE;
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.status4k !== MediaStatus.AVAILABLE
) {
existing.status4k = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (!existing.mediaAddedAt && !changedExisting) {
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
changedExisting = true;
}
if (
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
existing.ratingKey !== plexitem.ratingKey
) {
existing.ratingKey = plexitem.ratingKey;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.ratingKey4k !== plexitem.ratingKey
) {
existing.ratingKey4k = plexitem.ratingKey;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Request for ${metadata.title} exists. New media types set to AVAILABLE`,
'info'
);
} else {
this.log(
`Title already exists and no new media types found ${metadata.title}`
);
}
} 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;
newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000);
newMedia.status =
hasOtherResolution || (!this.enable4kMovie && has4k)
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.status4k =
has4k && this.enable4kMovie
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.ratingKey =
hasOtherResolution || (!this.enable4kMovie && has4k)
? plexitem.ratingKey
: undefined;
newMedia.ratingKey4k =
has4k && this.enable4kMovie ? plexitem.ratingKey : undefined;
await mediaRepository.save(newMedia);
this.log(`Saved ${tmdbMovie.title}`);
}
});
}
// this adds all movie episodes from specials season for Hama agent
private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
const specials = metadata.Children?.Metadata.find(
(md) => Number(md.index) === 0
);
if (specials) {
const episodes = await this.plexClient.getChildrenMetadata(
specials.ratingKey
);
if (episodes) {
for (const episode of episodes) {
const special = animeList.getSpecialEpisode(tvdbId, episode.index);
if (special) {
if (special.tmdbId) {
await this.processMovieWithId(episode, undefined, special.tmdbId);
} else if (special.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: special.imdbId,
});
await this.processMovieWithId(episode, tmdbMovie, tmdbMovie.id);
}
}
}
}
}
}
private async processShow(plexitem: PlexLibraryItem) {
const mediaRepository = getRepository(Media);
let tvShow: TmdbTvDetails | null = null;
try {
const ratingKey =
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey;
const metadata = await this.plexClient.getMetadata(ratingKey, {
includeChildren: true,
});
if (metadata.guid.match(tvdbRegex)) {
const matchedtvdb = metadata.guid.match(tvdbRegex);
// If we can find a tvdb Id, use it to get the full tmdb show details
if (matchedtvdb?.[1]) {
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
}
} else if (metadata.guid.match(tmdbShowRegex)) {
const matchedtmdb = metadata.guid.match(tmdbShowRegex);
if (matchedtmdb?.[1]) {
tvShow = await this.tmdb.getTvShow({ tvId: Number(matchedtmdb[1]) });
}
} else if (metadata.guid.match(hamaTvdbRegex)) {
const matched = metadata.guid.match(hamaTvdbRegex);
const tvdbId = matched?.[1];
if (tvdbId) {
tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: Number(tvdbId) });
if (animeList.isLoaded()) {
await this.processHamaSpecials(metadata, Number(tvdbId));
} else {
this.log(
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
'warn'
);
}
}
} else if (metadata.guid.match(hamaAnidbRegex)) {
const matched = metadata.guid.match(hamaAnidbRegex);
if (!animeList.isLoaded()) {
this.log(
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
'warn'
);
} else if (matched?.[1]) {
const anidbId = Number(matched[1]);
const result = animeList.getFromAnidbId(anidbId);
// first try to lookup tvshow by tvdbid
if (result?.tvdbId) {
const extResponse = await this.tmdb.getByExternalId({
externalId: result.tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
tvShow = await this.tmdb.getTvShow({
tvId: extResponse.tv_results[0].id,
});
} else {
this.log(
`Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
);
}
await this.processHamaSpecials(metadata, result.tvdbId);
}
if (!tvShow) {
// if lookup of tvshow above failed, then try movie with tmdbid/imdbid
// note - some tv shows have imdbid set too, that's why this need to go second
if (result?.tmdbId) {
return await this.processMovieWithId(
plexitem,
undefined,
result.tmdbId
);
} else if (result?.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: result.imdbId,
});
return await this.processMovieWithId(
plexitem,
tmdbMovie,
tmdbMovie.id
);
}
}
}
}
if (tvShow) {
await this.asyncLock.dispatch(tvShow.id, async () => {
if (!tvShow) {
// this will never execute, but typescript thinks somebody could reset tvShow from
// outer scope back to null before this async gets called
return;
}
// Lets get the available seasons from Plex
const seasons = tvShow.seasons;
const media = await this.getExisting(tvShow.id, MediaType.TV);
const newSeasons: Season[] = [];
const currentStandardSeasonAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const current4kSeasonAvailable = (
media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
for (const season of seasons) {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
);
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.season_number
);
// Check if we found the matching season and it has all the available episodes
if (matchedPlexSeason) {
// If we have a matched Plex season, get its children metadata so we can check details
const episodes = await this.plexClient.getChildrenMetadata(
matchedPlexSeason.ratingKey
);
// Total episodes that are in standard definition (not 4k)
const totalStandard = episodes.filter((episode) =>
!this.enable4kShow
? true
: episode.Media.some(
(media) => media.videoResolution !== '4k'
)
).length;
// Total episodes that are in 4k
const total4k = episodes.filter((episode) =>
episode.Media.some((media) => media.videoResolution === '4k')
).length;
if (
media &&
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
media.ratingKey !== ratingKey
) {
media.ratingKey = ratingKey;
}
if (
media &&
total4k > 0 &&
this.enable4kShow &&
media.ratingKey4k !== ratingKey
) {
media.ratingKey4k = ratingKey;
}
if (existingSeason) {
// These ternary statements look super confusing, but they are simply
// setting the status to AVAILABLE if all of a type is there, partially if some,
// and then not modifying the status if there are 0 items
existingSeason.status =
totalStandard === season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
this.enable4kShow && total4k === season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
seasonNumber: season.season_number,
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
// if we dont have any items for the season
status:
totalStandard === season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow && total4k === season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
})
);
}
}
}
// Remove extras season. We dont count it for determining availability
const filteredSeasons = tvShow.seasons.filter(
(season) => season.season_number !== 0
);
const isAllStandardSeasons =
newSeasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
const isAll4kSeasons =
newSeasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
if (media) {
// Update existing
media.seasons = [...media.seasons, ...newSeasons];
const newStandardSeasonAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
this.log(
`Detected ${
newStandardSeasonAvailable - currentStandardSeasonAvailable
} new standard season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
media.mediaAddedAt = new Date(plexitem.addedAt * 1000);
}
if (new4kSeasonAvailable > current4kSeasonAvailable) {
this.log(
`Detected ${
new4kSeasonAvailable - current4kSeasonAvailable
} new 4K season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (!media.mediaAddedAt) {
media.mediaAddedAt = new Date(plexitem.addedAt * 1000);
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status !== MediaStatus.UNKNOWN
).length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status4k !== MediaStatus.UNKNOWN
).length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`);
}
});
} else {
this.log(`failed show: ${plexitem.guid}`);
}
} catch (e) {
this.log(
`Failed to process Plex item. ratingKey: ${
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey
}`,
'error',
{
errorMessage: e.message,
plexitem,
}
);
}
}
private async processItems(slicedItems: PlexLibraryItem[]) {
await Promise.all(
slicedItems.map(async (plexitem) => {
if (plexitem.type === 'movie') {
await this.processMovie(plexitem);
} else if (
plexitem.type === 'show' ||
plexitem.type === 'episode' ||
plexitem.type === 'season'
) {
await this.processShow(plexitem);
}
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Plex Sync', ...optional });
}
// checks if any of this.libraries has Hama agent set in Plex
private async hasHamaAgent() {
const plexLibraries = await this.plexClient.getLibraries();
return this.libraries.some((library) =>
plexLibraries.some(
(plexLibrary) =>
plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
)
);
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = uuid();
this.sessionId = sessionId;
logger.info('Plex Sync Starting', { sessionId, label: 'Plex Sync' });
try {
this.running = true;
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
if (!admin) {
return this.log('No admin configured. Plex sync skipped.', 'warn');
}
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
'info'
);
}
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4kShow) {
this.log(
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
'info'
);
}
const hasHama = await this.hasHamaAgent();
if (hasHama) {
await animeList.sync();
}
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({ sessionId });
}
} else {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.plexClient.getLibraryContents(library.id);
await this.loop({ sessionId });
}
}
this.log(
this.isRecentOnly
? 'Recently Added Scan Complete'
: 'Full Scan Complete',
'info'
);
} catch (e) {
logger.error('Sync interrupted', {
label: 'Plex Sync',
errorMessage: e.message,
});
} finally {
// If a new scanning session hasnt started, set running back to false
if (this.sessionId === sessionId) {
this.running = false;
}
}
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentLibrary: this.currentLibrary,
libraries: this.libraries,
};
}
public cancel(): void {
this.running = false;
}
}
export const jobPlexFullSync = new JobPlexSync();
export const jobPlexRecentSync = new JobPlexSync({ isRecentOnly: true });

View File

@@ -1,248 +0,0 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import RadarrAPI, { RadarrMovie } from '../../api/radarr';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger';
const BUNDLE_SIZE = 50;
const UPDATE_RATE = 4 * 1000;
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentServer: RadarrSettings;
servers: RadarrSettings[];
}
class JobRadarrSync {
private running = false;
private progress = 0;
private enable4k = false;
private sessionId: string;
private servers: RadarrSettings[];
private currentServer: RadarrSettings;
private radarrApi: RadarrAPI;
private items: RadarrMovie[] = [];
public async run() {
const settings = getSettings();
const sessionId = uuid();
this.sessionId = sessionId;
this.log('Radarr sync starting', 'info', { sessionId });
try {
this.running = true;
// Remove any duplicate Radarr servers and assign them to the servers field
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
return (
radarrA.hostname === radarrB.hostname &&
radarrA.port === radarrB.port &&
radarrA.baseUrl === radarrB.baseUrl
);
});
this.enable4k = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4k) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled.',
'info'
);
}
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Radarr server: ${server.name}`,
'info'
);
this.radarrApi = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
});
this.items = await this.radarrApi.getMovies();
await this.loop({ sessionId });
} else {
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
}
}
this.log('Radarr sync complete', 'info');
} catch (e) {
this.log('Something went wrong.', 'error', { errorMessage: e.message });
} finally {
// If a new scanning session hasnt started, set running back to false
if (this.sessionId === sessionId) {
this.running = false;
}
}
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public cancel(): void {
this.running = false;
}
private async processRadarrMovie(radarrMovie: RadarrMovie) {
const mediaRepository = getRepository(Media);
const server4k = this.enable4k && this.currentServer.is4k;
const media = await mediaRepository.findOne({
where: { tmdbId: radarrMovie.tmdbId },
});
if (media) {
let isChanged = false;
if (media.status === MediaStatus.AVAILABLE) {
this.log(`Movie already available: ${radarrMovie.title}`);
} else {
media[server4k ? 'status4k' : 'status'] = radarrMovie.downloaded
? MediaStatus.AVAILABLE
: MediaStatus.PROCESSING;
this.log(
`Updated existing ${server4k ? '4K ' : ''}movie ${
radarrMovie.title
} to status ${MediaStatus[media[server4k ? 'status4k' : 'status']]}`
);
isChanged = true;
}
if (
media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
) {
media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
this.log(`Updated service ID for media entity: ${radarrMovie.title}`);
isChanged = true;
}
if (
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
radarrMovie.id
) {
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
radarrMovie.id;
this.log(
`Updated external service ID for media entity: ${radarrMovie.title}`
);
isChanged = true;
}
if (
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
radarrMovie.titleSlug
) {
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
radarrMovie.titleSlug;
this.log(
`Updated external service slug for media entity: ${radarrMovie.title}`
);
isChanged = true;
}
if (isChanged) {
await mediaRepository.save(media);
}
} else {
const newMedia = new Media({
tmdbId: radarrMovie.tmdbId,
imdbId: radarrMovie.imdbId,
mediaType: MediaType.MOVIE,
serviceId: !server4k ? this.currentServer.id : undefined,
serviceId4k: server4k ? this.currentServer.id : undefined,
externalServiceId: !server4k ? radarrMovie.id : undefined,
externalServiceId4k: server4k ? radarrMovie.id : undefined,
status:
!server4k && radarrMovie.downloaded
? MediaStatus.AVAILABLE
: !server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
server4k && radarrMovie.downloaded
? MediaStatus.AVAILABLE
: server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
this.log(
`Added media for movie ${radarrMovie.title} and set status to ${
MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
}`
);
await mediaRepository.save(newMedia);
}
}
private async processItems(items: RadarrMovie[]) {
await Promise.all(
items.map(async (radarrMovie) => {
await this.processRadarrMovie(radarrMovie);
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Radarr Sync', ...optional });
}
}
export const jobRadarrSync = new JobRadarrSync();

View File

@@ -1,9 +1,9 @@
import schedule from 'node-schedule';
import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
import logger from '../logger';
import { jobRadarrSync } from './radarrsync';
import { jobSonarrSync } from './sonarrsync';
import downloadTracker from '../lib/downloadtracker';
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
import { radarrScanner } from '../lib/scanners/radarr';
import { sonarrScanner } from '../lib/scanners/sonarr';
interface ScheduledJob {
id: string;
@@ -17,58 +17,60 @@ interface ScheduledJob {
export const scheduledJobs: ScheduledJob[] = [];
export const startJobs = (): void => {
// Run recently added plex sync every 5 minutes
// Run recently added plex scan every 5 minutes
scheduledJobs.push({
id: 'plex-recently-added-sync',
name: 'Plex Recently Added Sync',
id: 'plex-recently-added-scan',
name: 'Plex Recently Added Scan',
type: 'process',
job: schedule.scheduleJob('0 */5 * * * *', () => {
logger.info('Starting scheduled job: Plex Recently Added Sync', {
logger.info('Starting scheduled job: Plex Recently Added Scan', {
label: 'Jobs',
});
jobPlexRecentSync.run();
plexRecentScanner.run();
}),
running: () => jobPlexRecentSync.status().running,
cancelFn: () => jobPlexRecentSync.cancel(),
running: () => plexRecentScanner.status().running,
cancelFn: () => plexRecentScanner.cancel(),
});
// Run full plex sync every 24 hours
// Run full plex scan every 24 hours
scheduledJobs.push({
id: 'plex-full-sync',
name: 'Plex Full Library Sync',
id: 'plex-full-scan',
name: 'Plex Full Library Scan',
type: 'process',
job: schedule.scheduleJob('0 0 3 * * *', () => {
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
jobPlexFullSync.run();
logger.info('Starting scheduled job: Plex Full Library Scan', {
label: 'Jobs',
});
plexFullScanner.run();
}),
running: () => jobPlexFullSync.status().running,
cancelFn: () => jobPlexFullSync.cancel(),
running: () => plexFullScanner.status().running,
cancelFn: () => plexFullScanner.cancel(),
});
// Run full radarr sync every 24 hours
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-sync',
name: 'Radarr Sync',
id: 'radarr-scan',
name: 'Radarr Scan',
type: 'process',
job: schedule.scheduleJob('0 0 4 * * *', () => {
logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' });
jobRadarrSync.run();
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
radarrScanner.run();
}),
running: () => jobRadarrSync.status().running,
cancelFn: () => jobRadarrSync.cancel(),
running: () => radarrScanner.status().running,
cancelFn: () => radarrScanner.cancel(),
});
// Run full sonarr sync every 24 hours
// Run full sonarr scan every 24 hours
scheduledJobs.push({
id: 'sonarr-sync',
name: 'Sonarr Sync',
id: 'sonarr-scan',
name: 'Sonarr Scan',
type: 'process',
job: schedule.scheduleJob('0 30 4 * * *', () => {
logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' });
jobSonarrSync.run();
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
sonarrScanner.run();
}),
running: () => jobSonarrSync.status().running,
cancelFn: () => jobSonarrSync.cancel(),
running: () => sonarrScanner.status().running,
cancelFn: () => sonarrScanner.cancel(),
});
// Run download sync

View File

@@ -1,381 +0,0 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import SonarrAPI, { SonarrSeries } from '../../api/sonarr';
import TheMovieDb from '../../api/themoviedb';
import { TmdbTvDetails } from '../../api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import { getSettings, SonarrSettings } from '../../lib/settings';
import logger from '../../logger';
const BUNDLE_SIZE = 50;
const UPDATE_RATE = 4 * 1000;
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentServer: SonarrSettings;
servers: SonarrSettings[];
}
class JobSonarrSync {
private running = false;
private progress = 0;
private enable4k = false;
private sessionId: string;
private servers: SonarrSettings[];
private currentServer: SonarrSettings;
private sonarrApi: SonarrAPI;
private items: SonarrSeries[] = [];
public async run() {
const settings = getSettings();
const sessionId = uuid();
this.sessionId = sessionId;
this.log('Sonarr sync starting', 'info', { sessionId });
try {
this.running = true;
// Remove any duplicate Sonarr servers and assign them to the servers field
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
return (
sonarrA.hostname === sonarrB.hostname &&
sonarrA.port === sonarrB.port &&
sonarrA.baseUrl === sonarrB.baseUrl
);
});
this.enable4k = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4k) {
this.log(
'At least one 4K Sonarr server was detected. 4K movie detection is now enabled.',
'info'
);
}
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Sonarr server: ${server.name}`,
'info'
);
this.sonarrApi = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
});
this.items = await this.sonarrApi.getSeries();
await this.loop({ sessionId });
} else {
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
}
}
this.log('Sonarr sync complete', 'info');
} catch (e) {
this.log('Something went wrong.', 'error', { errorMessage: e.message });
} finally {
// If a new scanning session hasnt started, set running back to false
if (this.sessionId === sessionId) {
this.running = false;
}
}
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public cancel(): void {
this.running = false;
}
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
const mediaRepository = getRepository(Media);
const server4k = this.enable4k && this.currentServer.is4k;
const media = await mediaRepository.findOne({
where: { tvdbId: sonarrSeries.tvdbId },
});
const currentSeasonsAvailable = (media?.seasons ?? []).filter(
(season) =>
season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length;
const newSeasons: Season[] = [];
for (const season of sonarrSeries.seasons) {
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.seasonNumber
);
// We are already tracking this season so we can work on it directly
if (existingSeason) {
if (
existingSeason[server4k ? 'status4k' : 'status'] !==
MediaStatus.AVAILABLE &&
season.statistics
) {
existingSeason[server4k ? 'status4k' : 'status'] =
season.statistics.episodeFileCount ===
season.statistics.totalEpisodeCount
? MediaStatus.AVAILABLE
: season.statistics.episodeFileCount > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.monitored
? MediaStatus.PROCESSING
: existingSeason[server4k ? 'status4k' : 'status'];
}
} else {
if (season.statistics && season.seasonNumber !== 0) {
const allEpisodes =
season.statistics.episodeFileCount ===
season.statistics.totalEpisodeCount;
newSeasons.push(
new Season({
seasonNumber: season.seasonNumber,
status:
!server4k && allEpisodes
? MediaStatus.AVAILABLE
: !server4k && season.statistics.episodeFileCount > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !server4k && season.monitored
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
server4k && allEpisodes
? MediaStatus.AVAILABLE
: server4k && season.statistics.episodeFileCount > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !server4k && season.monitored
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
})
);
}
}
}
const filteredSeasons = sonarrSeries.seasons.filter(
(s) => s.seasonNumber !== 0
);
const isAllSeasons =
(media?.seasons ?? []).filter(
(s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length +
newSeasons.filter(
(s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length >=
filteredSeasons.length && filteredSeasons.length > 0;
if (media) {
media.seasons = [...media.seasons, ...newSeasons];
const newSeasonsAvailable = (media?.seasons ?? []).filter(
(season) =>
season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length;
if (newSeasonsAvailable > currentSeasonsAvailable) {
this.log(
`Detected ${newSeasonsAvailable - currentSeasonsAvailable} new ${
server4k ? '4K ' : ''
}season(s) for ${sonarrSeries.title}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (
media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
) {
media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
this.log(`Updated service ID for media entity: ${sonarrSeries.title}`);
}
if (
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
sonarrSeries.id
) {
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
sonarrSeries.id;
this.log(
`Updated external service ID for media entity: ${sonarrSeries.title}`
);
}
if (
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
sonarrSeries.titleSlug
) {
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
sonarrSeries.titleSlug;
this.log(
`Updated external service slug for media entity: ${sonarrSeries.title}`
);
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) =>
season[server4k ? 'status4k' : 'status'] !== MediaStatus.UNKNOWN
).length === 0;
media[server4k ? 'status4k' : 'status'] =
isAllSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) =>
season[server4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE ||
season[server4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: media.seasons.some(
(season) =>
season[server4k ? 'status4k' : 'status'] ===
MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
} else {
const tmdb = new TheMovieDb();
let tvShow: TmdbTvDetails;
try {
tvShow = await tmdb.getShowByTvdbId({
tvdbId: sonarrSeries.tvdbId,
});
} catch (e) {
this.log(
'Failed to create new media item during sync. TVDB ID is missing from TMDB?',
'warn',
{ sonarrSeries, errorMessage: e.message }
);
return;
}
const newMedia = new Media({
tmdbId: tvShow.id,
tvdbId: sonarrSeries.tvdbId,
mediaType: MediaType.TV,
serviceId: !server4k ? this.currentServer.id : undefined,
serviceId4k: server4k ? this.currentServer.id : undefined,
externalServiceId: !server4k ? sonarrSeries.id : undefined,
externalServiceId4k: server4k ? sonarrSeries.id : undefined,
externalServiceSlug: !server4k ? sonarrSeries.titleSlug : undefined,
externalServiceSlug4k: server4k ? sonarrSeries.titleSlug : undefined,
seasons: newSeasons,
status:
!server4k && isAllSeasons
? MediaStatus.AVAILABLE
: !server4k &&
newSeasons.some(
(s) =>
s.status === MediaStatus.PARTIALLY_AVAILABLE ||
s.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: !server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
server4k && isAllSeasons
? MediaStatus.AVAILABLE
: server4k &&
newSeasons.some(
(s) =>
s.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
s.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
this.log(
`Added media for series ${sonarrSeries.title} and set status to ${
MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
}`
);
await mediaRepository.save(newMedia);
}
}
private async processItems(items: SonarrSeries[]) {
await Promise.all(
items.map(async (sonarrSeries) => {
await this.processSonarrSeries(sonarrSeries);
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Sonarr Sync', ...optional });
}
}
export const jobSonarrSync = new JobSonarrSync();

View File

@@ -1,5 +1,6 @@
import { Notification } from '..';
import Media from '../../../entity/Media';
import { MediaRequest } from '../../../entity/MediaRequest';
import { User } from '../../../entity/User';
import { NotificationAgentConfig } from '../../settings';
@@ -10,6 +11,7 @@ export interface NotificationPayload {
image?: string;
message?: string;
extra?: { name: string; value: string }[];
request?: MediaRequest;
}
export abstract class BaseAgent<T extends NotificationAgentConfig> {
@@ -19,9 +21,15 @@ export abstract class BaseAgent<T extends NotificationAgentConfig> {
}
protected abstract getSettings(): T;
protected userNotificationTypes: Notification[] = [
Notification.MEDIA_APPROVED,
Notification.MEDIA_DECLINED,
Notification.MEDIA_AVAILABLE,
];
}
export interface NotificationAgent {
shouldSend(type: Notification): boolean;
shouldSend(type: Notification, payload: NotificationPayload): boolean;
send(type: Notification, payload: NotificationPayload): Promise<boolean>;
}

View File

@@ -71,9 +71,15 @@ interface DiscordRichEmbed {
interface DiscordWebhookPayload {
embeds: DiscordRichEmbed[];
username: string;
username?: string;
avatar_url?: string;
tts: boolean;
content?: string;
allowed_mentions?: {
parse?: ('users' | 'roles' | 'everyone')[];
roles?: string[];
users?: string[];
};
}
class DiscordAgent
@@ -98,106 +104,65 @@ class DiscordAgent
const fields: Field[] = [];
if (payload.request) {
fields.push({
name: 'Requested By',
value: payload.notifyUser.displayName ?? '',
inline: true,
});
}
switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.displayName ?? '',
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}`,
});
}
fields.push({
name: 'Status',
value: 'Pending Approval',
inline: true,
});
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
color = EmbedColors.PURPLE;
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.displayName ?? '',
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}`,
});
}
fields.push({
name: 'Status',
value: 'Processing',
inline: true,
});
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.displayName ?? '',
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}`,
});
}
fields.push({
name: 'Status',
value: 'Available',
inline: true,
});
break;
case Notification.MEDIA_DECLINED:
color = EmbedColors.RED;
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.displayName ?? '',
inline: true,
},
{
name: 'Status',
value: 'Declined',
inline: true,
}
);
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
fields.push({
name: 'Status',
value: 'Declined',
inline: true,
});
break;
case Notification.MEDIA_FAILED:
color = EmbedColors.RED;
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
fields.push({
name: 'Status',
value: 'Failed',
inline: true,
});
break;
}
if (settings.main.applicationUrl && payload.media) {
fields.push({
name: `Open in ${settings.main.applicationTitle}`,
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
return {
title: payload.subject,
description: payload.message,
@@ -237,18 +202,38 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending discord notification', { label: 'Notifications' });
logger.debug('Sending Discord notification', { label: 'Notifications' });
try {
const settings = getSettings();
const webhookUrl = this.getSettings().options.webhookUrl;
const {
botUsername,
botAvatarUrl,
webhookUrl,
} = this.getSettings().options;
if (!webhookUrl) {
return false;
}
const mentionedUsers: string[] = [];
let content = undefined;
if (
this.userNotificationTypes.includes(type) &&
payload.notifyUser.settings?.enableNotifications &&
payload.notifyUser.settings?.discordId
) {
mentionedUsers.push(payload.notifyUser.settings.discordId);
content = `<@${payload.notifyUser.settings.discordId}>`;
}
await axios.post(webhookUrl, {
username: settings.main.applicationTitle,
username: botUsername,
avatar_url: botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content,
allowed_mentions: {
users: mentionedUsers,
},
} as DiscordWebhookPayload);
return true;
@@ -256,6 +241,7 @@ class DiscordAgent
logger.error('Error sending Discord notification', {
label: 'Notifications',
message: e.message,
response: e.response.data,
});
return false;
}

View File

@@ -7,6 +7,7 @@ import { getRepository } from 'typeorm';
import { User } from '../../../entity/User';
import { Permission } from '../../permissions';
import PreparedEmail from '../../email';
import { MediaType } from '../../../constants/media';
class EmailAgent
extends BaseAgent<NotificationAgentEmail>
@@ -21,12 +22,13 @@ class EmailAgent
return settings.notifications.agents.email;
}
public shouldSend(type: Notification): boolean {
public shouldSend(type: Notification, payload: NotificationPayload): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
hasNotificationType(type, this.getSettings().types)
hasNotificationType(type, this.getSettings().types) &&
(payload.notifyUser.settings?.enableNotifications ?? true)
) {
return true;
}
@@ -56,7 +58,9 @@ class EmailAgent
to: user.email,
},
locals: {
body: 'A user has requested new media!',
body: `A user has requested a new ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
}!`,
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
@@ -66,13 +70,15 @@ class EmailAgent
: undefined,
applicationUrl,
applicationTitle,
requestType: 'New Request',
requestType: `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`,
},
});
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
@@ -102,8 +108,11 @@ class EmailAgent
to: user.email,
},
locals: {
body:
"A user's new request has failed to add to Sonarr or Radarr",
body: `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} could not be added to ${
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
}`,
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
@@ -113,13 +122,15 @@ class EmailAgent
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Failed Request',
requestType: `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`,
},
});
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
@@ -142,7 +153,9 @@ class EmailAgent
to: payload.notifyUser.email,
},
locals: {
body: 'Your request for the following media has been approved:',
body: `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been approved:`,
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
@@ -152,12 +165,64 @@ class EmailAgent
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Request Approved',
requestType: `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`,
},
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendMediaAutoApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
// Send to all users with the manage requests permission (or admins)
users
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
.forEach((user) => {
const email = new PreparedEmail();
email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: user.email,
},
locals: {
body: `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been automatically approved:`,
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`,
},
});
});
return true;
} catch (e) {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
@@ -180,7 +245,9 @@ class EmailAgent
to: payload.notifyUser.email,
},
locals: {
body: 'Your request for the following media was declined:',
body: `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} was declined:`,
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
@@ -190,12 +257,14 @@ class EmailAgent
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Request Declined',
requestType: `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`,
},
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
@@ -218,7 +287,9 @@ class EmailAgent
to: payload.notifyUser.email,
},
locals: {
body: 'Your requested media is now available!',
body: `The following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} you requested is now available!`,
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
@@ -228,12 +299,14 @@ class EmailAgent
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Now Available',
requestType: `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`,
},
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
@@ -260,7 +333,7 @@ class EmailAgent
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
@@ -281,6 +354,9 @@ class EmailAgent
case Notification.MEDIA_APPROVED:
this.sendMediaApprovedEmail(payload);
break;
case Notification.MEDIA_AUTO_APPROVED:
this.sendMediaAutoApprovedEmail(payload);
break;
case Notification.MEDIA_DECLINED:
this.sendMediaDeclinedEmail(payload);
break;

View File

@@ -0,0 +1,168 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushbullet } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { MediaType } from '../../../constants/media';
interface PushbulletPayload {
title: string;
body: string;
}
class PushbulletAgent
extends BaseAgent<NotificationAgentPushbullet>
implements NotificationAgent {
protected getSettings(): NotificationAgentPushbullet {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.pushbullet;
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.accessToken &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}
return false;
}
private constructMessageDetails(
type: Notification,
payload: NotificationPayload
): {
title: string;
body: string;
} {
let messageTitle = '';
let message = '';
const title = payload.subject;
const plot = payload.message;
const username = payload.notifyUser.displayName;
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Pending Approval`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Available`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Declined`;
break;
case Notification.MEDIA_FAILED:
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Failed`;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${plot}`;
break;
}
return {
title: messageTitle,
body: message,
};
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Pushbullet notification', { label: 'Notifications' });
try {
const endpoint = 'https://api.pushbullet.com/v2/pushes';
const { accessToken } = this.getSettings().options;
const { title, body } = this.constructMessageDetails(type, payload);
await axios.post(
endpoint,
{
type: 'note',
title: title,
body: body,
} as PushbulletPayload,
{
headers: {
'Access-Token': accessToken,
},
}
);
return true;
} catch (e) {
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
}
export default PushbulletAgent;

View File

@@ -3,12 +3,16 @@ import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushover } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { MediaType } from '../../../constants/media';
interface PushoverPayload {
token: string;
user: string;
title: string;
message: string;
url: string;
url_title: string;
priority: number;
html: number;
}
@@ -41,10 +45,19 @@ class PushoverAgent
private constructMessageDetails(
type: Notification,
payload: NotificationPayload
): { title: string; message: string } {
): {
title: string;
message: string;
url: string | undefined;
url_title: string | undefined;
priority: number;
} {
const settings = getSettings();
let messageTitle = '';
let message = '';
let url: string | undefined;
let url_title: string | undefined;
let priority = 0;
const title = payload.subject;
const plot = payload.message;
@@ -52,46 +65,91 @@ class PushoverAgent
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = 'New Request';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nPending Approval\n`;
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nPending Approval`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = 'Request Approved';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nProcessing Request\n`;
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nProcessing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nProcessing`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = 'Now Available';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nAvailable\n`;
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nAvailable`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = 'Request Declined';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nDeclined\n`;
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nDeclined`;
priority = 1;
break;
case Notification.MEDIA_FAILED:
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nFailed`;
priority = 1;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${username}\n`;
message += `${plot}`;
break;
}
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `<a href="${actionUrl}">Open in ${settings.main.applicationTitle}</a>`;
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
url_title = `Open in ${settings.main.applicationTitle}`;
}
return { title: messageTitle, message };
return {
title: messageTitle,
message,
url,
url_title,
priority,
};
}
public async send(
@@ -104,13 +162,22 @@ class PushoverAgent
const { accessToken, userToken } = this.getSettings().options;
const { title, message } = this.constructMessageDetails(type, payload);
const {
title,
message,
url,
url_title,
priority,
} = this.constructMessageDetails(type, payload);
await axios.post(endpoint, {
token: accessToken,
user: userToken,
title: title,
message: message,
url: url,
url_title: url_title,
priority: priority,
html: 1,
} as PushoverPayload);

View File

@@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { MediaType } from '../../../constants/media';
interface EmbedField {
type: 'plain_text' | 'mrkdwn';
@@ -58,77 +59,80 @@ class SlackAgent
payload: NotificationPayload
): SlackBlockEmbed {
const settings = getSettings();
let header = settings.main.applicationTitle;
let header = '';
let actionUrl: string | undefined;
const fields: EmbedField[] = [];
if (payload.request) {
fields.push({
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
});
}
switch (type) {
case Notification.MEDIA_PENDING:
header = 'New Request';
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
text: '*Status*\nPending Approval',
}
);
if (settings.main.applicationUrl) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
header = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nPending Approval',
});
break;
case Notification.MEDIA_APPROVED:
header = 'Request Approved';
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
text: '*Status*\nProcessing Request',
}
);
if (settings.main.applicationUrl) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
});
break;
case Notification.MEDIA_DECLINED:
header = 'Request Declined';
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
text: '*Status*\nDeclined',
}
);
if (settings.main.applicationUrl) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
case Notification.MEDIA_AUTO_APPROVED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
});
break;
case Notification.MEDIA_AVAILABLE:
header = 'Now available!';
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
text: '*Status*\nAvailable',
}
);
if (settings.main.applicationUrl) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nAvailable',
});
break;
case Notification.MEDIA_DECLINED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nDeclined',
});
break;
case Notification.MEDIA_FAILED:
header = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nFailed',
});
break;
case Notification.TEST_NOTIFICATION:
header = 'Test Notification';
break;
}
if (settings.main.applicationUrl && payload.media) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
const blocks: EmbedBlock[] = [
@@ -139,14 +143,17 @@ class SlackAgent
text: header,
},
},
{
];
if (type !== Notification.TEST_NOTIFICATION) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*${payload.subject}*`,
},
},
];
});
}
if (payload.message) {
blocks.push({
@@ -191,7 +198,7 @@ class SlackAgent
value: 'open_overseerr',
text: {
type: 'plain_text',
text: `Open ${settings.main.applicationTitle}`,
text: `Open in ${settings.main.applicationTitle}`,
},
},
],
@@ -219,7 +226,7 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending slack notification', { label: 'Notifications' });
logger.debug('Sending Slack notification', { label: 'Notifications' });
try {
const webhookUrl = this.getSettings().options.webhookUrl;

View File

@@ -2,12 +2,22 @@ import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentTelegram } from '../../settings';
import { MediaType } from '../../../constants/media';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface TelegramPayload {
interface TelegramMessagePayload {
text: string;
parse_mode: string;
chat_id: string;
disable_notification: boolean;
}
interface TelegramPhotoPayload {
photo: string;
caption: string;
parse_mode: string;
chat_id: string;
disable_notification: boolean;
}
class TelegramAgent
@@ -52,53 +62,85 @@ class TelegramAgent
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
const user = this.escapeText(payload.notifyUser.displayName);
const applicationTitle = this.escapeText(settings.main.applicationTitle);
/* eslint-disable no-useless-escape */
switch (type) {
case Notification.MEDIA_PENDING:
message += `\*New Request\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n\n`;
message += `\*Status\*\nPending Approval\n`;
message += `\*New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nPending Approval`;
break;
case Notification.MEDIA_APPROVED:
message += `\*Request Approved\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n\n`;
message += `\*Status\*\nProcessing Request\n`;
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nProcessing`;
break;
case Notification.MEDIA_DECLINED:
message += `\*Request Declined\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n\n`;
message += `\*Status\*\nDeclined\n`;
case Notification.MEDIA_AUTO_APPROVED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nProcessing`;
break;
case Notification.MEDIA_AVAILABLE:
message += `\*Now available\\!\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n\n`;
message += `\*Status\*\nAvailable\n`;
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nAvailable`;
break;
case Notification.MEDIA_DECLINED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nDeclined`;
break;
case Notification.MEDIA_FAILED:
message += `\*Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nFailed`;
break;
case Notification.TEST_NOTIFICATION:
message += `\*Test Notification\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n`;
message += `\*Test Notification\*`;
message += `\n\n${plot}`;
break;
}
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\[Open in ${settings.main.applicationTitle}\]\(${actionUrl}\)`;
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
}
/* eslint-enable */
@@ -109,17 +151,53 @@ class TelegramAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending telegram notification', { label: 'Notifications' });
logger.debug('Sending Telegram notification', { label: 'Notifications' });
try {
const endpoint = `${this.baseUrl}bot${
this.getSettings().options.botAPI
}/sendMessage`;
}/${payload.image ? 'sendPhoto' : 'sendMessage'}`;
await axios.post(endpoint, {
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
} as TelegramPayload);
// Send system notification
await (payload.image
? axios.post(endpoint, {
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramPhotoPayload)
: axios.post(endpoint, {
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramMessagePayload));
// Send user notification
if (
this.userNotificationTypes.includes(type) &&
payload.notifyUser.settings?.enableNotifications &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !==
this.getSettings().options.chatId
) {
await (payload.image
? axios.post(endpoint, {
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramPhotoPayload)
: axios.post(endpoint, {
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload));
}
return true;
} catch (e) {

View File

@@ -19,6 +19,8 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
notifyuser_username: 'notifyUser.displayName',
notifyuser_email: 'notifyUser.email',
notifyuser_avatar: 'notifyUser.avatar',
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
media_tmdbid: 'media.tmdbId',
media_imdbid: 'media.imdbId',
media_tvdbid: 'media.tvdbId',
@@ -27,6 +29,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
payload.media?.status ? MediaStatus[payload.media?.status] : '',
media_status4k: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
request_id: 'request.id',
};
class WebhookAgent
@@ -60,6 +63,14 @@ class WebhookAgent
}
delete finalPayload[key];
key = 'media';
} else if (key === '{{request}}') {
if (payload.request) {
finalPayload.request = finalPayload[key];
} else {
finalPayload.request = null;
}
delete finalPayload[key];
key = 'request';
}
if (typeof finalPayload[key] === 'string') {
@@ -127,7 +138,7 @@ class WebhookAgent
return true;
} catch (e) {
logger.error('Error sending Webhook notification', {
logger.error('Error sending webhook notification', {
label: 'Notifications',
errorMessage: e.message,
});

View File

@@ -9,6 +9,7 @@ export enum Notification {
MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
MEDIA_DECLINED = 64,
MEDIA_AUTO_APPROVED = 128,
}
export const hasNotificationType = (
@@ -49,7 +50,7 @@ class NotificationManager {
label: 'Notifications',
});
this.activeAgents.forEach((agent) => {
if (settings.enabled && agent.shouldSend(type)) {
if (settings.enabled && agent.shouldSend(type, payload)) {
agent.send(type, payload);
}
});

View File

@@ -14,6 +14,9 @@ export enum Permission {
REQUEST_4K_TV = 4096,
REQUEST_ADVANCED = 8192,
REQUEST_VIEW = 16384,
AUTO_APPROVE_4K = 32768,
AUTO_APPROVE_4K_MOVIE = 65536,
AUTO_APPROVE_4K_TV = 131072,
}
export interface PermissionCheckOptions {

View File

@@ -0,0 +1,616 @@
import { getRepository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import TheMovieDb from '../../api/themoviedb';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import logger from '../../logger';
import AsyncLock from '../../utils/asyncLock';
import { getSettings } from '../settings';
// Default scan rates (can be overidden)
const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000;
export type StatusBase = {
running: boolean;
progress: number;
total: number;
};
export interface RunnableScanner<T> {
run: () => Promise<void>;
status: () => T & StatusBase;
}
export interface MediaIds {
tmdbId: number;
imdbId?: string;
tvdbId?: number;
isHama?: boolean;
}
interface ProcessOptions {
is4k?: boolean;
mediaAddedAt?: Date;
ratingKey?: string;
serviceId?: number;
externalServiceId?: number;
externalServiceSlug?: string;
title?: string;
processing?: boolean;
}
export interface ProcessableSeason {
seasonNumber: number;
totalEpisodes: number;
episodes: number;
episodes4k: number;
is4kOverride?: boolean;
processing?: boolean;
}
class BaseScanner<T> {
private bundleSize;
private updateRate;
protected progress = 0;
protected items: T[] = [];
protected scannerName: string;
protected enable4kMovie = false;
protected enable4kShow = false;
protected sessionId: string;
protected running = false;
readonly asyncLock = new AsyncLock();
readonly tmdb = new TheMovieDb();
protected constructor(
scannerName: string,
{
updateRate,
bundleSize,
}: {
updateRate?: number;
bundleSize?: number;
} = {}
) {
this.scannerName = scannerName;
this.bundleSize = bundleSize ?? BUNDLE_SIZE;
this.updateRate = updateRate ?? UPDATE_RATE;
}
private async getExisting(tmdbId: number, mediaType: MediaType) {
const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType },
});
return existing;
}
protected async processMovie(
tmdbId: number,
{
is4k = false,
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
externalServiceSlug,
processing = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbId, async () => {
const existing = await this.getExisting(tmdbId, MediaType.MOVIE);
if (existing) {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
changedExisting = true;
}
if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
changedExisting = true;
}
if (
ratingKey &&
existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
) {
existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
changedExisting = true;
}
if (
serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
) {
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
changedExisting = true;
}
if (
externalServiceId !== undefined &&
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
externalServiceId
) {
existing[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
changedExisting = true;
}
if (
externalServiceSlug !== undefined &&
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
externalServiceSlug
) {
existing[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Media for ${title} exists. Changed were detected and the title will be updated.`,
'info'
);
} else {
this.log(`Title already exists and no changes detected for ${title}`);
}
} else {
const newMedia = new Media();
newMedia.tmdbId = tmdbId;
newMedia.status =
!is4k && !processing
? MediaStatus.AVAILABLE
: !is4k && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.status4k =
is4k && this.enable4kMovie && !processing
? MediaStatus.AVAILABLE
: is4k && this.enable4kMovie && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.serviceId = !is4k ? serviceId : undefined;
newMedia.serviceId4k = is4k ? serviceId : undefined;
newMedia.externalServiceId = !is4k ? externalServiceId : undefined;
newMedia.externalServiceId4k = is4k ? externalServiceId : undefined;
newMedia.externalServiceSlug = !is4k ? externalServiceSlug : undefined;
newMedia.externalServiceSlug4k = is4k ? externalServiceSlug : undefined;
if (mediaAddedAt) {
newMedia.mediaAddedAt = mediaAddedAt;
}
if (ratingKey) {
newMedia.ratingKey = !is4k ? ratingKey : undefined;
newMedia.ratingKey4k =
is4k && this.enable4kMovie ? ratingKey : undefined;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
});
}
/**
* processShow takes a TMDb ID and an array of ProcessableSeasons, which
* should include the total episodes a sesaon has + the total available
* episodes that each season currently has. Unlike processMovie, this method
* does not take an `is4k` option. We handle both the 4k _and_ non 4k status
* in one method.
*
* Note: If 4k is not enable, ProcessableSeasons should combine their episode counts
* into the normal episodes properties and avoid using the 4k properties.
*/
protected async processShow(
tmdbId: number,
tvdbId: number,
seasons: ProcessableSeason[],
{
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
externalServiceSlug,
is4k = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbId, async () => {
const media = await this.getExisting(tmdbId, MediaType.TV);
const newSeasons: Season[] = [];
const currentStandardSeasonsAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const current4kSeasonsAvailable = (
media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
for (const season of seasons) {
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.seasonNumber
);
// We update the rating keys in the seasons loop because we need episode counts
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
media.ratingKey = ratingKey;
}
if (
media &&
season.episodes4k > 0 &&
this.enable4kShow &&
media.ratingKey4k !== ratingKey
) {
media.ratingKey4k = ratingKey;
}
if (existingSeason) {
// Here we update seasons if they already exist.
// If the season is already marked as available, we
// force it to stay available (to avoid competing scanners)
existingSeason.status =
(season.totalEpisodes === season.episodes && season.episodes > 0) ||
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled
existingSeason.status4k =
(this.enable4kShow &&
season.episodes4k === season.totalEpisodes &&
season.episodes4k > 0) ||
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
seasonNumber: season.seasonNumber,
status:
season.totalEpisodes === season.episodes && season.episodes > 0
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow &&
season.totalEpisodes === season.episodes4k &&
season.episodes4k > 0
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
})
);
}
}
const isAllStandardSeasons =
seasons.length &&
seasons.every(
(season) =>
season.episodes === season.totalEpisodes && season.episodes > 0
);
const isAll4kSeasons =
seasons.length &&
seasons.every(
(season) =>
season.episodes4k === season.totalEpisodes && season.episodes4k > 0
);
if (media) {
media.seasons = [...media.seasons, ...newSeasons];
const newStandardSeasonsAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonsAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newStandardSeasonsAvailable > currentStandardSeasonsAvailable) {
this.log(
`Detected ${
newStandardSeasonsAvailable - currentStandardSeasonsAvailable
} new standard season(s) for ${title}`,
'debug'
);
media.lastSeasonChange = new Date();
if (mediaAddedAt) {
media.mediaAddedAt = mediaAddedAt;
}
}
if (new4kSeasonsAvailable > current4kSeasonsAvailable) {
this.log(
`Detected ${
new4kSeasonsAvailable - current4kSeasonsAvailable
} new 4K season(s) for ${title}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (!media.mediaAddedAt && mediaAddedAt) {
media.mediaAddedAt = mediaAddedAt;
}
if (serviceId !== undefined) {
media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
}
if (externalServiceId !== undefined) {
media[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
}
if (externalServiceSlug !== undefined) {
media[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
.length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
.length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: media.seasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: media.seasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${title}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId,
tvdbId,
mediaAddedAt,
serviceId: !is4k ? serviceId : undefined,
serviceId4k: is4k ? serviceId : undefined,
externalServiceId: !is4k ? externalServiceId : undefined,
externalServiceId4k: is4k ? externalServiceId : undefined,
externalServiceSlug: !is4k ? externalServiceSlug : undefined,
externalServiceSlug4k: is4k ? externalServiceSlug : undefined,
ratingKey: newSeasons.some(
(sn) =>
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status === MediaStatus.AVAILABLE
)
? ratingKey
: undefined,
ratingKey4k:
this.enable4kShow &&
newSeasons.some(
(sn) =>
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status4k === MediaStatus.AVAILABLE
)
? ratingKey
: undefined,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${title}`);
}
});
}
/**
* Call startRun from child class whenever a run is starting to
* ensure required values are set
*
* Returns the session ID which is requried for the cleanup method
*/
protected startRun(): string {
const settings = getSettings();
const sessionId = uuid();
this.sessionId = sessionId;
this.log('Scan starting', 'info', { sessionId });
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
'info'
);
}
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4kShow) {
this.log(
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
'info'
);
}
this.running = true;
return sessionId;
}
/**
* Call at end of run loop to perform cleanup
*/
protected endRun(sessionId: string): void {
if (this.sessionId === sessionId) {
this.running = false;
}
}
public cancel(): void {
this.running = false;
}
protected async loop(
processFn: (item: T) => Promise<void>,
{
start = 0,
end = this.bundleSize,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}
): Promise<void> {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(processFn, slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop(processFn, {
start: start + this.bundleSize,
end: end + this.bundleSize,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, this.updateRate)
);
}
}
private async processItems(
processFn: (items: T) => Promise<void>,
items: T[]
) {
await Promise.all(
items.map(async (item) => {
await processFn(item);
})
);
}
protected log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: this.scannerName, ...optional });
}
}
export default BaseScanner;

View File

@@ -0,0 +1,463 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import animeList from '../../../api/animelist';
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import { User } from '../../../entity/User';
import { getSettings, Library } from '../../settings';
import BaseScanner, {
MediaIds,
RunnableScanner,
StatusBase,
ProcessableSeason,
} from '../baseScanner';
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
// Hama agent uses ASS naming, see details here:
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
const HAMA_AGENT = 'com.plexapp.agents.hama';
type SyncStatus = StatusBase & {
currentLibrary: Library;
libraries: Library[];
};
class PlexScanner
extends BaseScanner<PlexLibraryItem>
implements RunnableScanner<SyncStatus> {
private plexClient: PlexAPI;
private libraries: Library[];
private currentLibrary: Library;
private isRecentOnly = false;
public constructor(isRecentOnly = false) {
super('Plex Scan');
this.isRecentOnly = isRecentOnly;
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentLibrary: this.currentLibrary,
libraries: this.libraries,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
if (!admin) {
return this.log('No admin configured. Plex scan skipped.', 'warn');
}
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
const hasHama = await this.hasHamaAgent();
if (hasHama) {
await animeList.sync();
}
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(this.processItem.bind(this), { sessionId });
}
} else {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.plexClient.getLibraryContents(library.id);
await this.loop(this.processItem.bind(this), { sessionId });
}
}
this.log(
this.isRecentOnly
? 'Recently Added Scan Complete'
: 'Full Scan Complete',
'info'
);
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processItem(plexitem: PlexLibraryItem) {
try {
if (plexitem.type === 'movie') {
await this.processPlexMovie(plexitem);
} else if (
plexitem.type === 'show' ||
plexitem.type === 'episode' ||
plexitem.type === 'season'
) {
await this.processPlexShow(plexitem);
}
} catch (e) {
this.log('Failed to process Plex media', 'error', {
errorMessage: e.message,
title: plexitem.title,
});
}
}
private async processPlexMovie(plexitem: PlexLibraryItem) {
const mediaIds = await this.getMediaIds(plexitem);
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
const has4k = metadata.Media.some(
(media) => media.videoResolution === '4k'
);
await this.processMovie(mediaIds.tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
}
private async processPlexMovieByTmdbId(
plexitem: PlexMetadata,
tmdbId: number
) {
const has4k = plexitem.Media.some(
(media) => media.videoResolution === '4k'
);
await this.processMovie(tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
}
private async processPlexShow(plexitem: PlexLibraryItem) {
const ratingKey =
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey;
const metadata = await this.plexClient.getMetadata(ratingKey, {
includeChildren: true,
});
const mediaIds = await this.getMediaIds(metadata);
// If the media is from HAMA, and doesn't have a TVDb ID, we will treat it
// as a special HAMA movie
if (mediaIds.tmdbId && !mediaIds.tvdbId && mediaIds.isHama) {
this.processHamaMovie(metadata, mediaIds.tmdbId);
return;
}
// If the media is from HAMA and we have a TVDb ID, we will attempt
// to process any specials that may exist
if (mediaIds.tvdbId && mediaIds.isHama) {
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
}
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = [];
const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
);
if (matchedPlexSeason) {
// If we have a matched Plex season, get its children metadata so we can check details
const episodes = await this.plexClient.getChildrenMetadata(
matchedPlexSeason.ratingKey
);
// Total episodes that are in standard definition (not 4k)
const totalStandard = episodes.filter((episode) =>
!this.enable4kShow
? true
: episode.Media.some((media) => media.videoResolution !== '4k')
).length;
// Total episodes that are in 4k
const total4k = this.enable4kShow
? episodes.filter((episode) =>
episode.Media.some((media) => media.videoResolution === '4k')
).length
: 0;
processableSeasons.push({
seasonNumber: season.season_number,
episodes: totalStandard,
episodes4k: total4k,
totalEpisodes: season.episode_count,
});
} else {
processableSeasons.push({
seasonNumber: season.season_number,
episodes: 0,
episodes4k: 0,
totalEpisodes: season.episode_count,
});
}
}
if (mediaIds.tvdbId) {
await this.processShow(
mediaIds.tmdbId,
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
processableSeasons,
{
mediaAddedAt: new Date(metadata.addedAt * 1000),
ratingKey: ratingKey,
title: metadata.title,
}
);
}
}
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
const mediaIds: Partial<MediaIds> = {};
// Check if item is using new plex movie/tv agent
if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
// If there is no Guid field at all, then we bail
if (!metadata.Guid) {
throw new Error(
'No Guid metadata for this title. Skipping. (Try refreshing the metadata in Plex for this media!)'
);
}
// Map all IDs to MediaId object
metadata.Guid.forEach((ref) => {
if (ref.id.match(imdbRegex)) {
mediaIds.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
} else if (ref.id.match(tmdbRegex)) {
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
mediaIds.tmdbId = Number(tmdbMatch);
} else if (ref.id.match(tvdbRegex)) {
const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
mediaIds.tvdbId = Number(tvdbMatch);
}
});
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
if (mediaIds.imdbId && !mediaIds.tmdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: mediaIds.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
}
// Check if the agent is IMDb
} else if (plexitem.guid.match(imdbRegex)) {
const imdbMatch = plexitem.guid.match(imdbRegex);
if (imdbMatch) {
mediaIds.imdbId = imdbMatch[1];
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: mediaIds.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
}
// Check if the agent is TMDb
} else if (plexitem.guid.match(tmdbRegex)) {
const tmdbMatch = plexitem.guid.match(tmdbRegex);
if (tmdbMatch) {
mediaIds.tmdbId = Number(tmdbMatch[1]);
}
// Check if the agent is TVDb
} else if (plexitem.guid.match(tvdbRegex)) {
const matchedtvdb = plexitem.guid.match(tvdbRegex);
// If we can find a tvdb Id, use it to get the full tmdb show details
if (matchedtvdb) {
const show = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
mediaIds.tvdbId = Number(matchedtvdb[1]);
mediaIds.tmdbId = show.id;
}
// Check if the agent (for shows) is TMDb
} else if (plexitem.guid.match(tmdbShowRegex)) {
const matchedtmdb = plexitem.guid.match(tmdbShowRegex);
if (matchedtmdb) {
mediaIds.tmdbId = Number(matchedtmdb[1]);
}
// Check for HAMA (with TVDb guid)
} else if (plexitem.guid.match(hamaTvdbRegex)) {
const matchedtvdb = plexitem.guid.match(hamaTvdbRegex);
if (matchedtvdb) {
const show = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
mediaIds.tvdbId = Number(matchedtvdb[1]);
mediaIds.tmdbId = show.id;
// Set isHama to true, so we can know to add special processing to this item
mediaIds.isHama = true;
}
// Check for HAMA (with anidb guid)
} else if (plexitem.guid.match(hamaAnidbRegex)) {
const matchedhama = plexitem.guid.match(hamaAnidbRegex);
if (!animeList.isLoaded()) {
this.log(
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
'warn',
{ title: plexitem.title }
);
} else if (matchedhama) {
const anidbId = Number(matchedhama[1]);
const result = animeList.getFromAnidbId(anidbId);
let tvShow: TmdbTvDetails | null = null;
// Set isHama to true, so we can know to add special processing to this item
mediaIds.isHama = true;
// First try to lookup the show by TVDb ID
if (result?.tvdbId) {
const extResponse = await this.tmdb.getByExternalId({
externalId: result.tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
tvShow = await this.tmdb.getTvShow({
tvId: extResponse.tv_results[0].id,
});
mediaIds.tvdbId = result.tvdbId;
mediaIds.tmdbId = tvShow.id;
} else {
this.log(
`Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
);
}
}
if (!tvShow) {
// if lookup of tvshow above failed, then try movie with tmdbid/imdbid
// note - some tv shows have imdbid set too, that's why this need to go second
if (result?.tmdbId) {
mediaIds.tmdbId = result.tmdbId;
mediaIds.imdbId = result?.imdbId;
} else if (result?.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: result.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
mediaIds.imdbId = result.imdbId;
}
}
}
}
if (!mediaIds.tmdbId) {
throw new Error('Unable to find TMDb ID');
}
// We check above if we have the TMDb ID, so we can safely assert the type below
return mediaIds as MediaIds;
}
// movies with hama agent actually are tv shows with at least one episode in it
// try to get first episode of any season - cannot hardcode season or episode number
// because sometimes user can have it in other season/ep than s01e01
private async processHamaMovie(metadata: PlexMetadata, tmdbId: number) {
const season = metadata.Children?.Metadata[0];
if (season) {
const episodes = await this.plexClient.getChildrenMetadata(
season.ratingKey
);
if (episodes) {
await this.processPlexMovieByTmdbId(episodes[0], tmdbId);
}
}
}
// this adds all movie episodes from specials season for Hama agent
private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
const specials = metadata.Children?.Metadata.find(
(md) => Number(md.index) === 0
);
if (specials) {
const episodes = await this.plexClient.getChildrenMetadata(
specials.ratingKey
);
if (episodes) {
for (const episode of episodes) {
const special = animeList.getSpecialEpisode(tvdbId, episode.index);
if (special) {
if (special.tmdbId) {
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
} else if (special.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: special.imdbId,
});
await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
}
}
}
}
}
}
// checks if any of this.libraries has Hama agent set in Plex
private async hasHamaAgent() {
const plexLibraries = await this.plexClient.getLibraries();
return this.libraries.some((library) =>
plexLibraries.some(
(plexLibrary) =>
plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
)
);
}
}
export const plexFullScanner = new PlexScanner();
export const plexRecentScanner = new PlexScanner(true);

View File

@@ -0,0 +1,94 @@
import { uniqWith } from 'lodash';
import RadarrAPI, { RadarrMovie } from '../../../api/radarr';
import { getSettings, RadarrSettings } from '../../settings';
import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
type SyncStatus = StatusBase & {
currentServer: RadarrSettings;
servers: RadarrSettings[];
};
class RadarrScanner
extends BaseScanner<RadarrMovie>
implements RunnableScanner<SyncStatus> {
private servers: RadarrSettings[];
private currentServer: RadarrSettings;
private radarrApi: RadarrAPI;
constructor() {
super('Radarr Scan', { bundleSize: 50 });
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
return (
radarrA.hostname === radarrB.hostname &&
radarrA.port === radarrB.port &&
radarrA.baseUrl === radarrB.baseUrl
);
});
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Radarr server: ${server.name}`,
'info'
);
this.radarrApi = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
});
this.items = await this.radarrApi.getMovies();
await this.loop(this.processRadarrMovie.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
}
}
this.log('Radarr scan complete', 'info');
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processRadarrMovie(radarrMovie: RadarrMovie): Promise<void> {
try {
const server4k = this.enable4kMovie && this.currentServer.is4k;
await this.processMovie(radarrMovie.tmdbId, {
is4k: server4k,
serviceId: this.currentServer.id,
externalServiceId: radarrMovie.id,
externalServiceSlug: radarrMovie.titleSlug,
title: radarrMovie.title,
processing: !radarrMovie.downloaded,
});
} catch (e) {
this.log('Failed to process Radarr media', 'error', {
errorMessage: e.message,
title: radarrMovie.title,
});
}
}
}
export const radarrScanner = new RadarrScanner();

View File

@@ -0,0 +1,134 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import SonarrAPI, { SonarrSeries } from '../../../api/sonarr';
import Media from '../../../entity/Media';
import { getSettings, SonarrSettings } from '../../settings';
import BaseScanner, {
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '../baseScanner';
type SyncStatus = StatusBase & {
currentServer: SonarrSettings;
servers: SonarrSettings[];
};
class SonarrScanner
extends BaseScanner<SonarrSeries>
implements RunnableScanner<SyncStatus> {
private servers: SonarrSettings[];
private currentServer: SonarrSettings;
private sonarrApi: SonarrAPI;
constructor() {
super('Sonarr Scan', { bundleSize: 50 });
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
return (
sonarrA.hostname === sonarrB.hostname &&
sonarrA.port === sonarrB.port &&
sonarrA.baseUrl === sonarrB.baseUrl
);
});
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Sonarr server: ${server.name}`,
'info'
);
this.sonarrApi = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
});
this.items = await this.sonarrApi.getSeries();
await this.loop(this.processSonarrSeries.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
}
}
this.log('Sonarr scan complete', 'info');
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
try {
const mediaRepository = getRepository(Media);
const server4k = this.enable4kShow && this.currentServer.is4k;
const processableSeasons: ProcessableSeason[] = [];
let tmdbId: number;
const media = await mediaRepository.findOne({
where: { tvdbId: sonarrSeries.tvdbId },
});
if (!media || !media.tmdbId) {
const tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: sonarrSeries.tvdbId,
});
tmdbId = tvShow.id;
} else {
tmdbId = media.tmdbId;
}
const filteredSeasons = sonarrSeries.seasons.filter(
(sn) => sn.seasonNumber !== 0
);
for (const season of filteredSeasons) {
const totalAvailableEpisodes = season.statistics?.episodeFileCount ?? 0;
processableSeasons.push({
seasonNumber: season.seasonNumber,
episodes: !server4k ? totalAvailableEpisodes : 0,
episodes4k: server4k ? totalAvailableEpisodes : 0,
totalEpisodes: season.statistics?.totalEpisodeCount ?? 0,
processing: season.monitored && totalAvailableEpisodes === 0,
is4kOverride: server4k,
});
}
await this.processShow(tmdbId, sonarrSeries.tvdbId, processableSeasons, {
serviceId: this.currentServer.id,
externalServiceId: sonarrSeries.id,
externalServiceSlug: sonarrSeries.titleSlug,
title: sonarrSeries.title,
is4k: server4k,
});
} catch (e) {
this.log('Failed to process Sonarr media', 'error', {
errorMessage: e.message,
title: sonarrSeries.title,
});
}
}
}
export const sonarrScanner = new SonarrScanner();

View File

@@ -10,6 +10,17 @@ export interface Library {
enabled: boolean;
}
export interface Region {
iso_3166_1: string;
english_name: string;
}
export interface Language {
iso_639_1: string;
english_name: string;
name: string;
}
export interface PlexSettings {
name: string;
machineId?: string;
@@ -45,6 +56,8 @@ export interface SonarrSettings extends DVRSettings {
activeAnimeProfileId?: number;
activeAnimeProfileName?: string;
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
activeLanguageProfileId?: number;
enableSeasonFolders: boolean;
}
@@ -56,6 +69,8 @@ export interface MainSettings {
defaultPermissions: number;
hideAvailable: boolean;
localLogin: boolean;
region: string;
originalLanguage: string;
trustProxy: boolean;
}
@@ -69,6 +84,8 @@ interface FullPublicSettings extends PublicSettings {
localLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
region: string;
originalLanguage: string;
}
export interface NotificationAgentConfig {
@@ -78,6 +95,8 @@ export interface NotificationAgentConfig {
}
export interface NotificationAgentDiscord extends NotificationAgentConfig {
options: {
botUsername?: string;
botAvatarUrl?: string;
webhookUrl: string;
};
}
@@ -103,8 +122,16 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
export interface NotificationAgentTelegram extends NotificationAgentConfig {
options: {
botUsername?: string;
botAPI: string;
chatId: string;
sendSilently: boolean;
};
}
export interface NotificationAgentPushbullet extends NotificationAgentConfig {
options: {
accessToken: string;
};
}
@@ -113,7 +140,6 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
accessToken: string;
userToken: string;
priority: number;
sound: string;
};
}
@@ -126,17 +152,17 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
}
interface NotificationAgents {
email: NotificationAgentEmail;
discord: NotificationAgentDiscord;
email: NotificationAgentEmail;
pushbullet: NotificationAgentPushbullet;
pushover: NotificationAgentPushover;
slack: NotificationAgentSlack;
telegram: NotificationAgentTelegram;
pushover: NotificationAgentPushover;
webhook: NotificationAgentWebhook;
}
interface NotificationSettings {
enabled: boolean;
autoapprovalEnabled: boolean;
agents: NotificationAgents;
}
@@ -168,6 +194,8 @@ class Settings {
defaultPermissions: Permission.REQUEST,
hideAvailable: false,
localLogin: true,
region: '',
originalLanguage: '',
trustProxy: false,
},
plex: {
@@ -184,7 +212,6 @@ class Settings {
},
notifications: {
enabled: true,
autoapprovalEnabled: false,
agents: {
email: {
enabled: false,
@@ -202,6 +229,8 @@ class Settings {
enabled: false,
types: 0,
options: {
botUsername: '',
botAvatarUrl: '',
webhookUrl: '',
},
},
@@ -216,8 +245,17 @@ class Settings {
enabled: false,
types: 0,
options: {
botUsername: '',
botAPI: '',
chatId: '',
sendSilently: false,
},
},
pushbullet: {
enabled: false,
types: 0,
options: {
accessToken: '',
},
},
pushover: {
@@ -227,7 +265,6 @@ class Settings {
accessToken: '',
userToken: '',
priority: 0,
sound: '',
},
},
webhook: {
@@ -304,6 +341,8 @@ class Settings {
series4kEnabled: this.data.sonarr.some(
(sonarr) => sonarr.is4k && sonarr.isDefault
),
region: this.data.main.region,
originalLanguage: this.data.main.originalLanguage,
};
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddResetPasswordGuidAndExpiryDate1612482778137
implements MigrationInterface {
name = 'AddResetPasswordGuidAndExpiryDate1612482778137';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"`
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"`
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLanguageProfileId1612571545781 implements MigrationInterface {
name = 'AddLanguageProfileId1612571545781';
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, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" 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", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" 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, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, 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 "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "temporary_media_request"`
);
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserSettings1613615266968 implements MigrationInterface {
name = 'CreateUserSettings1613615266968';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"))`
);
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"))`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
await queryRunner.query(`DROP TABLE "user_settings"`);
}
}

View File

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

View File

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

View File

@@ -1,4 +1,8 @@
import type { TmdbMovieDetails } from '../api/themoviedb/interfaces';
import type {
TmdbMovieDetails,
TmdbMovieReleaseResult,
TmdbProductionCompany,
} from '../api/themoviedb/interfaces';
import {
ProductionCompany,
Genre,
@@ -48,6 +52,7 @@ export interface MovieDetails {
name: string;
}[];
releaseDate: string;
releases: TmdbMovieReleaseResult;
revenue: number;
runtime?: number;
spokenLanguages: {
@@ -75,6 +80,18 @@ export interface MovieDetails {
plexUrl?: string;
}
export const mapProductionCompany = (
company: TmdbProductionCompany
): ProductionCompany => ({
id: company.id,
name: company.name,
originCountry: company.origin_country,
description: company.description,
headquarters: company.headquarters,
homepage: company.homepage,
logoPath: company.logo_path,
});
export const mapMovieDetails = (
movie: TmdbMovieDetails,
media?: Media
@@ -87,14 +104,10 @@ export const mapMovieDetails = (
originalLanguage: movie.original_language,
originalTitle: movie.original_title,
popularity: movie.popularity,
productionCompanies: movie.production_companies.map((company) => ({
id: company.id,
logoPath: company.logo_path,
originCountry: company.origin_country,
name: company.name,
})),
productionCompanies: movie.production_companies.map(mapProductionCompany),
productionCountries: movie.production_countries,
releaseDate: movie.release_date,
releases: movie.release_dates,
revenue: movie.revenue,
spokenLanguages: movie.spoken_languages,
status: movie.status,

View File

@@ -9,12 +9,15 @@ import {
mapExternalIds,
Keyword,
mapVideos,
TvNetwork,
} from './common';
import type {
TmdbTvEpisodeResult,
TmdbTvSeasonResult,
TmdbTvDetails,
TmdbSeasonWithEpisodes,
TmdbTvRatingResult,
TmdbNetwork,
} from '../api/themoviedb/interfaces';
import type Media from '../entity/Media';
import { Video } from './Movie';
@@ -58,6 +61,7 @@ export interface TvDetails {
id: number;
backdropPath?: string;
posterPath?: string;
contentRatings: TmdbTvRatingResult;
createdBy: {
id: number;
name: string;
@@ -75,7 +79,7 @@ export interface TvDetails {
lastEpisodeToAir?: Episode;
name: string;
nextEpisodeToAir?: Episode;
networks: ProductionCompany[];
networks: TvNetwork[];
numberOfEpisodes: number;
numberOfSeasons: number;
originCountry: string[];
@@ -137,6 +141,15 @@ export const mapSeasonWithEpisodes = (
posterPath: season.poster_path,
});
export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({
id: network.id,
name: network.name,
originCountry: network.origin_country,
headquarters: network.headquarters,
homepage: network.homepage,
logoPath: network.logo_path,
});
export const mapTvDetails = (
show: TmdbTvDetails,
media?: Media
@@ -155,12 +168,7 @@ export const mapTvDetails = (
languages: show.languages,
lastAirDate: show.last_air_date,
name: show.name,
networks: show.networks.map((network) => ({
id: network.id,
name: network.name,
originCountry: network.origin_country,
logoPath: network.logo_path,
})),
networks: show.networks.map(mapNetwork),
numberOfEpisodes: show.number_of_episodes,
numberOfSeasons: show.number_of_seasons,
originCountry: show.origin_country,
@@ -174,6 +182,7 @@ export const mapTvDetails = (
originCountry: company.origin_country,
logoPath: company.logo_path,
})),
contentRatings: show.content_ratings,
spokenLanguages: show.spoken_languages.map((language) => ({
englishName: language.english_name,
iso_639_1: language.iso_639_1,

View File

@@ -14,6 +14,18 @@ export interface ProductionCompany {
logoPath?: string;
originCountry: string;
name: string;
description?: string;
headquarters?: string;
homepage?: string;
}
export interface TvNetwork {
id: number;
logoPath?: string;
originCountry?: string;
name: string;
headquarters?: string;
homepage?: string;
}
export interface Keyword {

View File

@@ -23,10 +23,10 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
where: { id: req.user.id },
});
return res.status(200).json(user.filter());
return res.status(200).json(user);
});
authRoutes.post('/login', async (req, res, next) => {
authRoutes.post('/plex', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as { authToken?: string };
@@ -35,7 +35,7 @@ authRoutes.post('/login', async (req, res, next) => {
return res.status(500).json({ error: 'You must provide an auth token' });
}
try {
// First we need to use this auth token to get the users email from plex tv
// First we need to use this auth token to get the users email from plex.tv
const plextv = new PlexTvAPI(body.authToken);
const account = await plextv.getUser();
@@ -45,12 +45,12 @@ authRoutes.post('/login', async (req, res, next) => {
});
if (user) {
// Let's check if their plex token is up to date
// Let's check if their Plex token is up-to-date
if (user.plexToken !== body.authToken) {
user.plexToken = body.authToken;
}
// Update the users avatar with their plex thumbnail (incase it changed)
// Update the user's avatar with their Plex thumbnail, in case it changed
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
@@ -80,14 +80,14 @@ authRoutes.post('/login', async (req, res, next) => {
// 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
// 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)) {
if (await mainPlexTv.checkUserAccess(account.id)) {
user = new User({
email: account.email,
plexUsername: account.username,
@@ -100,7 +100,7 @@ authRoutes.post('/login', async (req, res, next) => {
await userRepository.save(user);
} else {
logger.info(
'Failed login attempt from user without access to plex server',
'Failed sign-in attempt from user without access to the Plex server.',
{
label: 'Auth',
account: {
@@ -112,7 +112,7 @@ authRoutes.post('/login', async (req, res, next) => {
);
return next({
status: 403,
message: 'You do not have access to this Plex server',
message: 'You do not have access to this Plex server.',
});
}
}
@@ -139,11 +139,11 @@ authRoutes.post('/local', async (req, res, next) => {
const body = req.body as { email?: string; password?: string };
if (!settings.main.localLogin) {
return res.status(500).json({ error: 'Local user login is disabled' });
return res.status(500).json({ error: 'Local user sign-in is disabled.' });
} else if (!body.email || !body.password) {
return res
.status(500)
.json({ error: 'You must provide an email and a password' });
return res.status(500).json({
error: 'You must provide both an email address and a password.',
});
}
try {
const user = await userRepository.findOne({
@@ -155,17 +155,20 @@ authRoutes.post('/local', async (req, res, next) => {
// User doesn't exist or credentials are incorrect
if (!isCorrectCredentials) {
logger.info('Failed login attempt from user with incorrect credentials', {
label: 'Auth',
account: {
ip: req.ip,
email: body.email,
password: '__REDACTED__',
},
});
logger.info(
'Failed sign-in attempt from user with incorrect credentials.',
{
label: 'Auth',
account: {
ip: req.ip,
email: body.email,
password: '__REDACTED__',
},
}
);
return next({
status: 403,
message: 'You do not have access to this Plex server',
message: 'Your sign-in credentials are incorrect.',
});
}
@@ -176,7 +179,10 @@ authRoutes.post('/local', async (req, res, next) => {
return res.status(200).json(user?.filter() ?? {});
} catch (e) {
logger.error(e.message, { label: 'Auth' });
logger.error('Something went wrong while attempting to authenticate.', {
label: 'Auth',
error: e.message,
});
return next({
status: 500,
message: 'Something went wrong.',
@@ -184,7 +190,7 @@ authRoutes.post('/local', async (req, res, next) => {
}
});
authRoutes.get('/logout', (req, res, next) => {
authRoutes.post('/logout', (req, res, next) => {
req.session?.destroy((err) => {
if (err) {
return next({
@@ -197,4 +203,82 @@ authRoutes.get('/logout', (req, res, next) => {
});
});
authRoutes.post('/reset-password', async (req, res) => {
const userRepository = getRepository(User);
const body = req.body as { email?: string };
if (!body.email) {
return res
.status(500)
.json({ error: 'You must provide an email address.' });
}
const user = await userRepository.findOne({
where: { email: body.email },
});
if (user) {
await user.resetPassword();
userRepository.save(user);
logger.info('Successful request made for recovery link.', {
label: 'User Management',
context: { ip: req.ip, email: body.email },
});
} else {
logger.info('Failed request made to reset a password.', {
label: 'User Management',
context: { ip: req.ip, email: body.email },
});
}
return res.status(200).json({ status: 'ok' });
});
authRoutes.post('/reset-password/:guid', async (req, res, next) => {
const userRepository = getRepository(User);
try {
if (!req.body.password || req.body.password?.length < 8) {
const message =
'Failed to reset password. Password must be at least 8 characters long.';
logger.info(message, {
label: 'User Management',
context: { ip: req.ip, guid: req.params.guid },
});
return next({ status: 500, message: message });
}
const user = await userRepository.findOne({
where: { resetPasswordGuid: req.params.guid },
});
if (!user) {
throw new Error('Guid invalid.');
}
if (
!user.recoveryLinkExpirationDate ||
user.recoveryLinkExpirationDate <= new Date()
) {
throw new Error('Recovery link expired.');
}
await user.setPassword(req.body.password);
user.recoveryLinkExpirationDate = null;
userRepository.save(user);
logger.info(`Successfully reset password`, {
label: 'User Management',
context: { ip: req.ip, guid: req.params.guid, email: user.email },
});
return res.status(200).json({ status: 'ok' });
} catch (e) {
logger.info(`Failed to reset password. ${e.message}`, {
label: 'User Management',
context: { ip: req.ip, guid: req.params.guid },
});
return res.status(200).json({ status: 'ok' });
}
});
export default authRoutes;

View File

@@ -4,15 +4,44 @@ import { mapMovieResult, mapTvResult, mapPersonResult } from '../models/Search';
import Media from '../entity/Media';
import { isMovie, isPerson } from '../utils/typeHelpers';
import { MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
import { User } from '../entity/User';
import { mapProductionCompany } from '../models/Movie';
import { mapNetwork } from '../models/Tv';
const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
const settings = getSettings();
const region =
user?.settings?.region === 'all'
? ''
: user?.settings?.region
? user?.settings?.region
: settings.main.region;
const originalLanguage =
user?.settings?.originalLanguage === 'all'
? ''
: user?.settings?.originalLanguage
? user?.settings?.originalLanguage
: settings.main.originalLanguage;
return new TheMovieDb({
region,
originalLanguage,
});
};
const discoverRoutes = Router();
discoverRoutes.get('/movies', async (req, res) => {
const tmdb = new TheMovieDb();
const tmdb = createTmdbWithRegionLanaguage(req.user);
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.query.language as string,
genre: req.query.genre ? Number(req.query.genre) : undefined,
studio: req.query.studio ? Number(req.query.studio) : undefined,
});
const media = await Media.getRelatedMedia(
@@ -34,12 +63,146 @@ discoverRoutes.get('/movies', async (req, res) => {
});
});
discoverRoutes.get('/movies/upcoming', async (req, res) => {
const tmdb = new TheMovieDb();
discoverRoutes.get<{ language: string }>(
'/movies/language/:language',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
const data = await tmdb.getUpcomingMovies({
const languages = await tmdb.getLanguages();
const language = languages.find(
(lang) => lang.iso_639_1 === req.params.language
);
if (!language) {
return next({ status: 404, message: 'Unable to retrieve language' });
}
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.query.language as string,
originalLanguage: req.params.language,
});
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,
language,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});
}
);
discoverRoutes.get<{ genreId: string }>(
'/movies/genre/:genreId',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
const genres = await tmdb.getMovieGenres({
language: req.query.language as string,
});
const genre = genres.find(
(genre) => genre.id === Number(req.params.genreId)
);
if (!genre) {
return next({ status: 404, message: 'Unable to retrieve genre' });
}
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.query.language as string,
genre: Number(req.params.genreId),
});
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,
genre,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});
}
);
discoverRoutes.get<{ studioId: string }>(
'/movies/studio/:studioId',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const studio = await tmdb.getStudio(Number(req.params.studioId));
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.query.language as string,
studio: Number(req.params.studioId),
});
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,
studio: mapProductionCompany(studio),
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
),
});
} catch (e) {
return next({ status: 404, message: 'Unable to retrieve studio' });
}
}
);
discoverRoutes.get('/movies/upcoming', async (req, res) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
const now = new Date();
const offset = now.getTimezoneOffset();
const date = new Date(now.getTime() - offset * 60 * 1000)
.toISOString()
.split('T')[0];
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.query.language as string,
primaryReleaseDateGte: date,
});
const media = await Media.getRelatedMedia(
@@ -62,11 +225,172 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => {
});
discoverRoutes.get('/tv', async (req, res) => {
const tmdb = new TheMovieDb();
const tmdb = createTmdbWithRegionLanaguage(req.user);
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.query.language as string,
genre: req.query.genre ? Number(req.query.genre) : undefined,
network: req.query.network ? Number(req.query.network) : undefined,
});
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) =>
mapTvResult(
result,
media.find(
(med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
});
discoverRoutes.get<{ language: string }>(
'/tv/language/:language',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
const languages = await tmdb.getLanguages();
const language = languages.find(
(lang) => lang.iso_639_1 === req.params.language
);
if (!language) {
return next({ status: 404, message: 'Unable to retrieve language' });
}
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.query.language as string,
originalLanguage: req.params.language,
});
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,
language,
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
}
);
discoverRoutes.get<{ genreId: string }>(
'/tv/genre/:genreId',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
const genres = await tmdb.getTvGenres({
language: req.query.language as string,
});
const genre = genres.find(
(genre) => genre.id === Number(req.params.genreId)
);
if (!genre) {
return next({ status: 404, message: 'Unable to retrieve genre' });
}
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.query.language as string,
genre: Number(req.params.genreId),
});
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,
genre,
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
}
);
discoverRoutes.get<{ networkId: string }>(
'/tv/network/:networkId',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const network = await tmdb.getNetwork(Number(req.params.networkId));
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.query.language as string,
network: Number(req.params.networkId),
});
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,
network: mapNetwork(network),
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
} catch (e) {
return next({ status: 404, message: 'Unable to retrieve network' });
}
}
);
discoverRoutes.get('/tv/upcoming', async (req, res) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
const now = new Date();
const offset = now.getTimezoneOffset();
const date = new Date(now.getTime() - offset * 60 * 1000)
.toISOString()
.split('T')[0];
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.query.language as string,
firstAirDateGte: date,
});
const media = await Media.getRelatedMedia(
@@ -89,7 +413,7 @@ discoverRoutes.get('/tv', async (req, res) => {
});
discoverRoutes.get('/trending', async (req, res) => {
const tmdb = new TheMovieDb();
const tmdb = createTmdbWithRegionLanaguage(req.user);
const data = await tmdb.getAllTrending({
page: Number(req.query.page),
@@ -109,15 +433,18 @@ discoverRoutes.get('/trending', async (req, res) => {
? mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
: isPerson(result)
? mapPersonResult(result)
: mapTvResult(
result,
media.find((req) => req.tmdbId === result.id && MediaType.TV)
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
@@ -146,8 +473,8 @@ discoverRoutes.get<{ keywordId: string }>(
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
),

View File

@@ -16,6 +16,9 @@ import collectionRoutes from './collection';
import { getAppVersion, getCommitTag } from '../utils/appVersion';
import serviceRoutes from './service';
import { appDataStatus, appDataPath } from '../utils/appDataVolume';
import TheMovieDb from '../api/themoviedb';
import { mapProductionCompany } from '../models/Movie';
import { mapNetwork } from '../models/Tv';
const router = Router();
@@ -35,7 +38,7 @@ router.get('/status/appdata', (_req, res) => {
});
});
router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user);
router.use('/user', isAuthenticated(), user);
router.get('/settings/public', (_req, res) => {
const settings = getSettings();
@@ -57,6 +60,54 @@ router.use('/collection', isAuthenticated(), collectionRoutes);
router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/auth', authRoutes);
router.get('/regions', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb();
const regions = await tmdb.getRegions();
return res.status(200).json(regions);
});
router.get('/languages', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb();
const languages = await tmdb.getLanguages();
return res.status(200).json(languages);
});
router.get<{ id: string }>('/studio/:id', async (req, res) => {
const tmdb = new TheMovieDb();
const studio = await tmdb.getStudio(Number(req.params.id));
return res.status(200).json(mapProductionCompany(studio));
});
router.get<{ id: string }>('/network/:id', async (req, res) => {
const tmdb = new TheMovieDb();
const network = await tmdb.getNetwork(Number(req.params.id));
return res.status(200).json(mapNetwork(network));
});
router.get('/genres/movie', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb();
const genres = await tmdb.getMovieGenres();
return res.status(200).json(genres);
});
router.get('/genres/tv', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb();
const genres = await tmdb.getTvGenres();
return res.status(200).json(genres);
});
router.get('/', (_req, res) => {
return res.status(200).json({
api: 'Overseerr API',

View File

@@ -82,7 +82,7 @@ mediaRoutes.get('/', async (req, res, next) => {
}
});
mediaRoutes.get<
mediaRoutes.post<
{
id: string;
status: 'available' | 'partial' | 'processing' | 'pending' | 'unknown';
@@ -102,7 +102,7 @@ mediaRoutes.get<
return next({ status: 404, message: 'Media does not exist.' });
}
const is4k = Boolean(req.query.is4k);
const is4k = Boolean(req.body.is4k);
switch (req.params.status) {
case 'available':

View File

@@ -1,7 +1,7 @@
import { Router } from 'express';
import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm';
import { getRepository } from 'typeorm';
import { MediaRequest } from '../entity/MediaRequest';
import TheMovieDb from '../api/themoviedb';
import Media from '../entity/Media';
@@ -14,66 +14,102 @@ import { User } from '../entity/User';
const requestRoutes = Router();
requestRoutes.get('/', async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
try {
const pageSize = req.query.take ? Number(req.query.take) : 20;
const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0;
let statusFilter:
| MediaRequestStatus
| FindOperator<string | MediaRequestStatus>
| undefined = undefined;
let statusFilter: MediaRequestStatus[];
switch (req.query.filter) {
case 'approved':
case 'processing':
case 'available':
statusFilter = [MediaRequestStatus.APPROVED];
break;
case 'pending':
statusFilter = [MediaRequestStatus.PENDING];
break;
case 'unavailable':
statusFilter = [
MediaRequestStatus.PENDING,
MediaRequestStatus.APPROVED,
];
break;
default:
statusFilter = [
MediaRequestStatus.PENDING,
MediaRequestStatus.APPROVED,
MediaRequestStatus.DECLINED,
];
}
let mediaStatusFilter: MediaStatus[];
switch (req.query.filter) {
case 'available':
statusFilter = MediaRequestStatus.AVAILABLE;
break;
case 'approved':
statusFilter = MediaRequestStatus.APPROVED;
break;
case 'pending':
statusFilter = MediaRequestStatus.PENDING;
mediaStatusFilter = [MediaStatus.AVAILABLE];
break;
case 'processing':
case 'unavailable':
statusFilter = In([
MediaRequestStatus.PENDING,
MediaRequestStatus.APPROVED,
]);
mediaStatusFilter = [
MediaStatus.UNKNOWN,
MediaStatus.PENDING,
MediaStatus.PROCESSING,
MediaStatus.PARTIALLY_AVAILABLE,
];
break;
default:
statusFilter = In(Object.values(MediaRequestStatus));
mediaStatusFilter = [
MediaStatus.UNKNOWN,
MediaStatus.PENDING,
MediaStatus.PROCESSING,
MediaStatus.PARTIALLY_AVAILABLE,
MediaStatus.AVAILABLE,
];
}
let sortFilter: FindOneOptions<MediaRequest>['order'] = {
id: 'DESC',
};
let sortFilter: string;
switch (req.query.sort) {
case 'modified':
sortFilter = {
updatedAt: 'DESC',
};
sortFilter = 'request.updatedAt';
break;
default:
sortFilter = 'request.id';
}
const [requests, requestCount] = req.user?.hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
)
? await requestRepository.findAndCount({
order: sortFilter,
relations: ['media', 'modifiedBy'],
where: { status: statusFilter },
take: Number(req.query.take) ?? 20,
skip,
})
: await requestRepository.findAndCount({
where: { requestedBy: { id: req.user?.id }, status: statusFilter },
relations: ['media', 'modifiedBy'],
order: sortFilter,
take: Number(req.query.limit) ?? 20,
skip,
});
let query = getRepository(MediaRequest)
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.leftJoinAndSelect('request.seasons', 'seasons')
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
.where('request.status IN (:...requestStatus)', {
requestStatus: statusFilter,
})
.andWhere(
'((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))',
{
mediaStatus: mediaStatusFilter,
}
);
if (
!req.user?.hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
)
) {
query = query.andWhere('requestedBy.id = :id', {
id: req.user?.id,
});
}
const [requests, requestCount] = await query
.orderBy(sortFilter, 'DESC')
.take(pageSize)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
@@ -175,16 +211,34 @@ requestRoutes.post(
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
? req.user
: undefined,
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
@@ -231,34 +285,60 @@ requestRoutes.post(
const request = new MediaRequest({
type: MediaType.TV,
media: {
id: media.id,
} as Media,
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
? req.user
: undefined,
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
languageProfileId: req.body.languageProfileId,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
seasonNumber: sn,
status:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
})
),
});
@@ -278,16 +358,51 @@ requestRoutes.get('/count', async (_req, res, next) => {
const requestRepository = getRepository(MediaRequest);
try {
const pendingCount = await requestRepository.count({
status: MediaRequestStatus.PENDING,
});
const approvedCount = await requestRepository.count({
status: MediaRequestStatus.APPROVED,
});
const query = requestRepository
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media');
const pendingCount = await query
.where('request.status = :requestStatus', {
requestStatus: MediaRequestStatus.PENDING,
})
.getCount();
const approvedCount = await query
.where('request.status = :requestStatus', {
requestStatus: MediaRequestStatus.APPROVED,
})
.getCount();
const processingCount = await query
.where('request.status = :requestStatus', {
requestStatus: MediaRequestStatus.APPROVED,
})
.andWhere(
'(request.is4k = false AND media.status != :availableStatus) OR (request.is4k = true AND media.status4k != :availableStatus)',
{
availableStatus: MediaStatus.AVAILABLE,
}
)
.getCount();
const availableCount = await query
.where('request.status = :requestStatus', {
requestStatus: MediaRequestStatus.APPROVED,
})
.andWhere(
'(request.is4k = false AND media.status = :availableStatus) OR (request.is4k = true AND media.status4k = :availableStatus)',
{
availableStatus: MediaStatus.AVAILABLE,
}
)
.getCount();
return res.status(200).json({
pending: pendingCount,
approved: approvedCount,
processing: processingCount,
available: availableCount,
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -488,7 +603,7 @@ requestRoutes.post<{
}
);
requestRoutes.get<{
requestRoutes.post<{
requestId: string;
status: 'pending' | 'approve' | 'decline';
}>(

View File

@@ -46,9 +46,7 @@ serviceRoutes.get<{ radarrId: string }>(
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
});
const profiles = await radarr.getProfiles();
@@ -90,6 +88,8 @@ serviceRoutes.get('/sonarr', async (req, res) => {
activeProfileId: sonarr.activeProfileId,
activeAnimeProfileId: sonarr.activeAnimeProfileId,
activeAnimeDirectory: sonarr.activeAnimeDirectory,
activeLanguageProfileId: sonarr.activeLanguageProfileId,
activeAnimeLanguageProfileId: sonarr.activeAnimeLanguageProfileId,
})
);
@@ -114,36 +114,43 @@ serviceRoutes.get<{ sonarrId: string }>(
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
});
const profiles = await sonarr.getProfiles();
const rootFolders = await sonarr.getRootFolders();
try {
const profiles = await sonarr.getProfiles();
const rootFolders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles();
return res.status(200).json({
server: {
id: sonarrSettings.id,
name: sonarrSettings.name,
is4k: sonarrSettings.is4k,
isDefault: sonarrSettings.isDefault,
activeDirectory: sonarrSettings.activeDirectory,
activeProfileId: sonarrSettings.activeProfileId,
activeAnimeProfileId: sonarrSettings.activeAnimeProfileId,
activeAnimeDirectory: sonarrSettings.activeAnimeDirectory,
},
profiles: profiles.map((profile) => ({
id: profile.id,
name: profile.name,
})),
rootFolders: rootFolders.map((folder) => ({
id: folder.id,
freeSpace: folder.freeSpace,
path: folder.path,
totalSpace: folder.totalSpace,
})),
} as ServiceCommonServerWithDetails);
return res.status(200).json({
server: {
id: sonarrSettings.id,
name: sonarrSettings.name,
is4k: sonarrSettings.is4k,
isDefault: sonarrSettings.isDefault,
activeDirectory: sonarrSettings.activeDirectory,
activeProfileId: sonarrSettings.activeProfileId,
activeAnimeProfileId: sonarrSettings.activeAnimeProfileId,
activeAnimeDirectory: sonarrSettings.activeAnimeDirectory,
activeLanguageProfileId: sonarrSettings.activeLanguageProfileId,
activeAnimeLanguageProfileId:
sonarrSettings.activeAnimeLanguageProfileId,
},
profiles: profiles.map((profile) => ({
id: profile.id,
name: profile.name,
})),
rootFolders: rootFolders.map((folder) => ({
id: folder.id,
freeSpace: folder.freeSpace,
path: folder.path,
totalSpace: folder.totalSpace,
})),
languageProfiles: languageProfiles,
} as ServiceCommonServerWithDetails);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);

View File

@@ -4,7 +4,6 @@ import { getRepository } from 'typeorm';
import { User } from '../../entity/User';
import PlexAPI from '../../api/plexapi';
import PlexTvAPI from '../../api/plextv';
import { jobPlexFullSync } from '../../job/plexsync';
import { scheduledJobs } from '../../job/schedule';
import { Permission } from '../../lib/permissions';
import { isAuthenticated } from '../../middleware/auth';
@@ -17,6 +16,7 @@ import notificationRoutes from './notifications';
import sonarrRoutes from './sonarr';
import radarrRoutes from './radarr';
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
import { plexFullScanner } from '../../lib/scanners/plex';
const settingsRoutes = Router();
@@ -54,7 +54,7 @@ settingsRoutes.post('/main', (req, res) => {
return res.status(200).json(settings.main);
});
settingsRoutes.get('/main/regenerate', (req, res, next) => {
settingsRoutes.post('/main/regenerate', (req, res, next) => {
const settings = getSettings();
const main = settings.regenerateApiKey();
@@ -210,13 +210,17 @@ settingsRoutes.get('/plex/library', async (req, res) => {
return res.status(200).json(settings.plex.libraries);
});
settingsRoutes.get('/plex/sync', (req, res) => {
if (req.query.cancel) {
jobPlexFullSync.cancel();
} else if (req.query.start) {
jobPlexFullSync.run();
settingsRoutes.get('/plex/sync', (_req, res) => {
return res.status(200).json(plexFullScanner.status());
});
settingsRoutes.post('/plex/sync', (req, res) => {
if (req.body.cancel) {
plexFullScanner.cancel();
} else if (req.body.start) {
plexFullScanner.run();
}
return res.status(200).json(jobPlexFullSync.status());
return res.status(200).json(plexFullScanner.status());
});
settingsRoutes.get('/jobs', (_req, res) => {
@@ -231,7 +235,7 @@ settingsRoutes.get('/jobs', (_req, res) => {
);
});
settingsRoutes.get<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
const scheduledJob = scheduledJobs.find((job) => job.id === req.params.jobId);
if (!scheduledJob) {
@@ -249,7 +253,7 @@ settingsRoutes.get<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
});
});
settingsRoutes.get<{ jobId: string }>(
settingsRoutes.post<{ jobId: string }>(
'/jobs/:jobId/cancel',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -286,7 +290,7 @@ settingsRoutes.get('/cache', (req, res) => {
);
});
settingsRoutes.get<{ cacheId: AvailableCacheIds }>(
settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
'/cache/:cacheId/flush',
(req, res, next) => {
const cache = cacheManager.getCache(req.params.cacheId);
@@ -300,7 +304,7 @@ settingsRoutes.get<{ cacheId: AvailableCacheIds }>(
}
);
settingsRoutes.get(
settingsRoutes.post(
'/initialize',
isAuthenticated(Permission.ADMIN),
(_req, res) => {

View File

@@ -7,6 +7,7 @@ import SlackAgent from '../../lib/notifications/agents/slack';
import TelegramAgent from '../../lib/notifications/agents/telegram';
import PushoverAgent from '../../lib/notifications/agents/pushover';
import WebhookAgent from '../../lib/notifications/agents/webhook';
import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
const notificationRoutes = Router();
@@ -14,7 +15,6 @@ notificationRoutes.get('/', (_req, res) => {
const settings = getSettings().notifications;
return res.status(200).json({
enabled: settings.enabled,
autoapprovalEnabled: settings.autoapprovalEnabled,
});
});
@@ -23,13 +23,11 @@ notificationRoutes.post('/', (req, res) => {
Object.assign(settings.notifications, {
enabled: req.body.enabled,
autoapprovalEnabled: req.body.autoapprovalEnabled,
});
settings.save();
return res.status(200).json({
enabled: settings.notifications.enabled,
autoapprovalEnabled: settings.notifications.autoapprovalEnabled,
});
});
@@ -135,6 +133,40 @@ notificationRoutes.post('/telegram/test', (req, res, next) => {
return res.status(204).send();
});
notificationRoutes.get('/pushbullet', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.notifications.agents.pushbullet);
});
notificationRoutes.post('/pushbullet', (req, res) => {
const settings = getSettings();
settings.notifications.agents.pushbullet = req.body;
settings.save();
res.status(200).json(settings.notifications.agents.pushbullet);
});
notificationRoutes.post('/pushbullet/test', (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
});
}
const pushbulletAgent = new PushbulletAgent(req.body);
pushbulletAgent.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();
});
notificationRoutes.get('/pushover', (_req, res) => {
const settings = getSettings();

View File

@@ -39,9 +39,7 @@ radarrRoutes.post('/test', async (req, res, next) => {
try {
const radarr = new RadarrAPI({
apiKey: req.body.apiKey,
url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${
req.body.port
}${req.body.baseUrl ?? ''}/api`,
url: RadarrAPI.buildRadarrUrl(req.body, '/api/v3'),
});
const profiles = await radarr.getProfiles();
@@ -112,9 +110,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
});
const profiles = await radarr.getProfiles();

View File

@@ -39,13 +39,12 @@ sonarrRoutes.post('/test', async (req, res, next) => {
try {
const sonarr = new SonarrAPI({
apiKey: req.body.apiKey,
url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${
req.body.port
}${req.body.baseUrl ?? ''}/api`,
url: SonarrAPI.buildSonarrUrl(req.body, '/api/v3'),
});
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles();
return res.status(200).json({
profiles,
@@ -53,6 +52,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
id: folder.id,
path: folder.path,
})),
languageProfiles,
});
} catch (e) {
logger.error('Failed to test Sonarr', {

View File

@@ -1,264 +0,0 @@
import { Router } from 'express';
import { getRepository, Not } 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';
import gravatarUrl from 'gravatar-url';
import { UserType } from '../constants/user';
const router = Router();
router.get('/', async (_req, res) => {
const userRepository = getRepository(User);
const users = await userRepository.find();
return res.status(200).json(User.filterMany(users));
});
router.post('/', async (req, res, next) => {
try {
const settings = getSettings();
const body = req.body;
const userRepository = getRepository(User);
const passedExplicitPassword = body.password && body.password.length > 0;
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
if (!passedExplicitPassword && !settings.notifications.agents.email) {
throw new Error('Email notifications must be enabled');
}
const user = new User({
avatar: body.avatar ?? avatar,
username: body.username ?? body.email,
email: body.email,
password: body.password,
permissions: settings.main.defaultPermissions,
plexToken: '',
userType: UserType.LOCAL,
});
if (passedExplicitPassword) {
await user?.setPassword(body.password);
} else {
await user?.resetPassword();
}
await userRepository.save(user);
return res.status(201).json(user.filter());
} catch (e) {
next({ status: 500, message: e.message });
}
});
router.get<{ id: string }>('/:id', async (req, res, next) => {
try {
const userRepository = getRepository(User);
const user = await userRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
return res.status(200).json(user.filter());
} catch (e) {
next({ status: 404, message: 'User not found' });
}
});
const canMakePermissionsChange = (permissions: number, user?: User) =>
// Only let the owner grant admin privileges
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) ||
// Only let users with the manage settings permission, grant the same permission
!(
hasPermission(Permission.MANAGE_SETTINGS, permissions) &&
!hasPermission(Permission.MANAGE_SETTINGS, user?.permissions ?? 0)
);
router.put<
Record<string, never>,
Partial<User>[],
{ ids: string[]; permissions: number }
>('/', async (req, res, next) => {
try {
const isOwner = req.user?.id === 1;
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
return next({
status: 403,
message: 'You do not have permission to grant this level of access',
});
}
const userRepository = getRepository(User);
const users = await userRepository.findByIds(req.body.ids, {
...(!isOwner ? { id: Not(1) } : {}),
});
const updatedUsers = await Promise.all(
users.map(async (user) => {
return userRepository.save(<User>{
...user,
...{ permissions: req.body.permissions },
});
})
);
return res.status(200).json(updatedUsers);
} catch (e) {
next({ status: 500, message: e.message });
}
});
router.put<{ id: string }>('/:id', async (req, res, next) => {
try {
const userRepository = getRepository(User);
const user = await userRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
// Only let the owner user modify themselves
if (user.id === 1 && req.user?.id !== 1) {
return next({
status: 403,
message: 'You do not have permission to modify this user',
});
}
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
return next({
status: 403,
message: 'You do not have permission to grant this level of access',
});
}
Object.assign(user, {
username: req.body.username,
permissions: req.body.permissions,
});
await userRepository.save(user);
return res.status(200).json(user.filter());
} catch (e) {
next({ status: 404, message: 'User not found' });
}
});
router.delete<{ id: string }>('/:id', async (req, res, next) => {
try {
const userRepository = getRepository(User);
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) {
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 }, { email: account.email }],
});
if (user) {
// Update the users avatar with their plex thumbnail (incase it changed)
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// in-case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
if (user.username === account.username) {
user.username = '';
}
}
await userRepository.save(user);
} else {
// Check to make sure it's a real account
if (account.email && account.username) {
const newUser = new User({
plexUsername: account.username,
email: account.email,
permissions: settings.main.defaultPermissions,
plexId: parseInt(account.id),
plexToken: '',
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
}
}
return res.status(201).json(User.filterMany(createdUsers));
} catch (e) {
next({ status: 500, message: e.message });
}
});
export default router;

383
server/routes/user/index.ts Normal file
View File

@@ -0,0 +1,383 @@
import { Router } from 'express';
import { getRepository, Not } 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';
import gravatarUrl from 'gravatar-url';
import { UserType } from '../../constants/user';
import { isAuthenticated } from '../../middleware/auth';
import { UserResultsResponse } from '../../interfaces/api/userInterfaces';
import { UserRequestsResponse } from '../../interfaces/api/userInterfaces';
import userSettingsRoutes from './usersettings';
const router = Router();
router.get('/', async (req, res, next) => {
try {
const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0;
let query = getRepository(User).createQueryBuilder('user');
switch (req.query.sort) {
case 'updated':
query = query.orderBy('user.updatedAt', 'DESC');
break;
case 'displayname':
query = query.orderBy(
'(CASE WHEN user.username IS NULL THEN user.plexUsername ELSE user.username END)',
'ASC'
);
break;
case 'requests':
query = query
.addSelect((subQuery) => {
return subQuery
.select('COUNT(request.id)', 'requestCount')
.from(MediaRequest, 'request')
.where('request.requestedBy.id = user.id');
}, 'requestCount')
.orderBy('requestCount', 'DESC');
break;
default:
query = query.orderBy('user.id', 'ASC');
break;
}
const [users, userCount] = await query
.take(pageSize)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(userCount / pageSize),
pageSize,
results: userCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: User.filterMany(
users,
req.user?.hasPermission(Permission.MANAGE_USERS)
),
} as UserResultsResponse);
} catch (e) {
next({ status: 500, message: e.message });
}
});
router.post(
'/',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
try {
const settings = getSettings();
const body = req.body;
const userRepository = getRepository(User);
const passedExplicitPassword = body.password && body.password.length > 0;
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
if (!passedExplicitPassword && !settings.notifications.agents.email) {
throw new Error('Email notifications must be enabled');
}
const user = new User({
avatar: body.avatar ?? avatar,
username: body.username ?? body.email,
email: body.email,
password: body.password,
permissions: settings.main.defaultPermissions,
plexToken: '',
userType: UserType.LOCAL,
});
if (passedExplicitPassword) {
await user?.setPassword(body.password);
} else {
await user?.generatePassword();
}
await userRepository.save(user);
return res.status(201).json(user.filter());
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
router.get<{ id: string }>('/:id', async (req, res, next) => {
try {
const userRepository = getRepository(User);
const user = await userRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
return res
.status(200)
.json(user.filter(req.user?.hasPermission(Permission.MANAGE_USERS)));
} catch (e) {
next({ status: 404, message: 'User not found.' });
}
});
router.use('/:id/settings', userSettingsRoutes);
router.get<{ id: string }, UserRequestsResponse>(
'/:id/requests',
async (req, res, next) => {
const userRepository = getRepository(User);
const requestRepository = getRepository(MediaRequest);
const pageSize = req.query.take ? Number(req.query.take) : 20;
const skip = req.query.skip ? Number(req.query.skip) : 0;
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
const [requests, requestCount] = await requestRepository.findAndCount({
where: { requestedBy: user },
order: { id: 'DESC' },
take: pageSize,
skip,
});
return res.status(200).json({
pageInfo: {
pages: Math.ceil(requestCount / pageSize),
pageSize,
results: requestCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: requests,
});
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
export const canMakePermissionsChange = (
permissions: number,
user?: User
): boolean =>
// Only let the owner grant admin privileges
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) ||
// Only let users with the manage settings permission, grant the same permission
!(
hasPermission(Permission.MANAGE_SETTINGS, permissions) &&
!hasPermission(Permission.MANAGE_SETTINGS, user?.permissions ?? 0)
);
router.put<
Record<string, never>,
Partial<User>[],
{ ids: string[]; permissions: number }
>('/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => {
try {
const isOwner = req.user?.id === 1;
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
return next({
status: 403,
message: 'You do not have permission to grant this level of access',
});
}
const userRepository = getRepository(User);
const users = await userRepository.findByIds(req.body.ids, {
...(!isOwner ? { id: Not(1) } : {}),
});
const updatedUsers = await Promise.all(
users.map(async (user) => {
return userRepository.save(<User>{
...user,
...{ permissions: req.body.permissions },
});
})
);
return res.status(200).json(updatedUsers);
} catch (e) {
next({ status: 500, message: e.message });
}
});
router.put<{ id: string }>(
'/:id',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
try {
const userRepository = getRepository(User);
const user = await userRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
// Only let the owner user modify themselves
if (user.id === 1 && req.user?.id !== 1) {
return next({
status: 403,
message: 'You do not have permission to modify this user',
});
}
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
return next({
status: 403,
message: 'You do not have permission to grant this level of access',
});
}
Object.assign(user, {
username: req.body.username,
permissions: req.body.permissions,
});
await userRepository.save(user);
return res.status(200).json(user.filter());
} catch (e) {
next({ status: 404, message: 'User not found.' });
}
}
);
router.delete<{ id: string }>(
'/:id',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
try {
const userRepository = getRepository(User);
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
relations: ['requests'],
});
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) {
logger.error('Something went wrong deleting a user', {
label: 'API',
userId: req.params.id,
errorMessage: e.message,
});
return next({
status: 500,
message: 'Something went wrong deleting the user',
});
}
}
);
router.post(
'/import-from-plex',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
try {
const settings = getSettings();
const userRepository = getRepository(User);
// 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 }, { email: account.email }],
});
if (user) {
// Update the users avatar with their plex thumbnail (incase it changed)
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// in-case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
if (user.username === account.username) {
user.username = '';
}
}
await userRepository.save(user);
} else {
// Check to make sure it's a real account
if (
account.email &&
account.username &&
(await mainPlexTv.checkUserAccess(Number(account.id)))
) {
const newUser = new User({
plexUsername: account.username,
email: account.email,
permissions: settings.main.defaultPermissions,
plexId: parseInt(account.id),
plexToken: '',
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
}
}
return res.status(201).json(User.filterMany(createdUsers));
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
export default router;

View File

@@ -0,0 +1,337 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import { canMakePermissionsChange } from '.';
import { User } from '../../entity/User';
import { getSettings } from '../../lib/settings';
import { UserSettings } from '../../entity/UserSettings';
import {
UserSettingsGeneralResponse,
UserSettingsNotificationsResponse,
} from '../../interfaces/api/userSettingsInterfaces';
import { Permission } from '../../lib/permissions';
import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth';
const isOwnProfileOrAdmin = (): Middleware => {
const authMiddleware: Middleware = (req, res, next) => {
if (
!req.user?.hasPermission(Permission.MANAGE_USERS) &&
req.user?.id !== Number(req.params.id)
) {
return next({
status: 403,
message: "You do not have permission to view this user's settings.",
});
}
next();
};
return authMiddleware;
};
const userSettingsRoutes = Router({ mergeParams: true });
userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
'/main',
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
return res.status(200).json({
username: user.username,
region: user.settings?.region,
originalLanguage: user.settings?.originalLanguage,
});
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
userSettingsRoutes.post<
{ id: string },
UserSettingsGeneralResponse,
UserSettingsGeneralResponse
>('/main', isOwnProfileOrAdmin(), async (req, res, next) => {
const userRepository = getRepository(User);
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
user.username = req.body.username;
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
region: req.body.region,
originalLanguage: req.body.originalLanguage,
});
} else {
user.settings.region = req.body.region;
user.settings.originalLanguage = req.body.originalLanguage;
}
await userRepository.save(user);
return res.status(200).json({ username: user.username });
} catch (e) {
next({ status: 500, message: e.message });
}
});
userSettingsRoutes.get<{ id: string }, { hasPassword: boolean }>(
'/password',
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
select: ['id', 'password'],
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
return res.status(200).json({ hasPassword: !!user.password });
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
userSettingsRoutes.post<
{ id: string },
null,
{ currentPassword?: string; newPassword: string }
>('/password', isOwnProfileOrAdmin(), async (req, res, next) => {
const userRepository = getRepository(User);
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
const userWithPassword = await userRepository.findOne({
select: ['id', 'password'],
where: { id: Number(req.params.id) },
});
if (!user || !userWithPassword) {
return next({ status: 404, message: 'User not found.' });
}
if (req.body.newPassword.length < 8) {
return next({
status: 400,
message: 'Password must be at least 8 characters.',
});
}
if (
(user.id === 1 && req.user?.id !== 1) ||
(user.hasPermission(Permission.ADMIN) &&
user.id !== req.user?.id &&
req.user?.id !== 1)
) {
return next({
status: 403,
message: "You do not have permission to modify this user's password.",
});
}
// If the user has the permission to manage users and they are not
// editing themselves, we will just set the new password
if (
req.user?.hasPermission(Permission.MANAGE_USERS) &&
req.user?.id !== user.id
) {
await user.setPassword(req.body.newPassword);
await userRepository.save(user);
logger.debug('Password overriden by user.', {
label: 'User Settings',
userEmail: user.email,
changingUser: req.user.email,
});
return res.status(204).send();
}
// If the user has a password, we need to check the currentPassword is correct
if (
user.password &&
(!req.body.currentPassword ||
!(await userWithPassword.passwordMatch(req.body.currentPassword)))
) {
logger.debug(
'Attempt to change password for user failed. Invalid current password provided.',
{ label: 'User Settings', userEmail: user.email }
);
return next({ status: 403, message: 'Current password is invalid.' });
}
await user.setPassword(req.body.newPassword);
await userRepository.save(user);
return res.status(204).send();
} catch (e) {
next({ status: 500, message: e.message });
}
});
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
'/notifications',
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
const settings = getSettings();
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
return res.status(200).json({
enableNotifications: user.settings?.enableNotifications ?? true,
telegramBotUsername:
settings?.notifications.agents.telegram.options.botUsername,
discordId: user.settings?.discordId,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
});
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
userSettingsRoutes.post<
{ id: string },
UserSettingsNotificationsResponse,
UserSettingsNotificationsResponse
>('/notifications', isOwnProfileOrAdmin(), async (req, res, next) => {
const userRepository = getRepository(User);
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
enableNotifications: req.body.enableNotifications,
discordId: req.body.discordId,
telegramChatId: req.body.telegramChatId,
telegramSendSilently: req.body.telegramSendSilently,
});
} else {
user.settings.enableNotifications = req.body.enableNotifications;
user.settings.discordId = req.body.discordId;
user.settings.telegramChatId = req.body.telegramChatId;
user.settings.telegramSendSilently = req.body.telegramSendSilently;
}
userRepository.save(user);
return res.status(200).json({
enableNotifications: user.settings.enableNotifications,
discordId: user.settings.discordId,
telegramChatId: user.settings.telegramChatId,
telegramSendSilently: user.settings.telegramSendSilently,
});
} catch (e) {
next({ status: 500, message: e.message });
}
});
userSettingsRoutes.get<{ id: string }, { permissions?: number }>(
'/permissions',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
const userRepository = getRepository(User);
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
return res.status(200).json({ permissions: user.permissions });
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
userSettingsRoutes.post<
{ id: string },
{ permissions?: number },
{ permissions: number }
>(
'/permissions',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
const userRepository = getRepository(User);
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
// Only let the owner user modify themselves
if (user.id === 1 && req.user?.id !== 1) {
return next({
status: 403,
message: 'You do not have permission to modify this user',
});
}
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
return next({
status: 403,
message: 'You do not have permission to grant this level of access',
});
}
user.permissions = req.body.permissions;
await userRepository.save(user);
return res.status(200).json({ permissions: user.permissions });
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
export default userSettingsRoutes;

View File

@@ -35,6 +35,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
message: movie.overview,
media: entity,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: request,
});
});
}
@@ -96,6 +97,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
.join(', '),
},
],
request: request,
});
}
}

View File

@@ -0,0 +1 @@
!= `Account Information [${applicationTitle}]`

View File

@@ -1 +1 @@
= `${requestType}: ${mediaName} - ${applicationTitle}`
!= `${requestType} - ${mediaName} [${applicationTitle}]`

View File

@@ -1 +0,0 @@
= `Password Reset - ${applicationTitle}`

View File

@@ -0,0 +1,100 @@
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;\
')
| #{applicationTitle}
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; text-align: center; padding-bottom: 14px;')
| A request to reset the password was made. Click
a(href=applicationUrl style='color: #3869d4; padding: 0px 5px;') here
| to set a new password.
div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
| If you did not request this recovery link you can safely ignore this email.
p(style='\
font-size: 13px;\
line-height: 24px;\
margin-top: 6px;\
margin-bottom: 20px;\
color: #51545e;\
')
a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle}
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;\
')
| #{applicationTitle}.

View File

@@ -0,0 +1 @@
!= `Password Reset [${applicationTitle}]`

View File

@@ -1 +1 @@
= `Test Notification - ${applicationTitle}`
!= `Test Notification [${applicationTitle}]`

View File

@@ -11,7 +11,7 @@ confinement: strict
parts:
overseerr:
plugin: nodejs
nodejs-version: "12.18.4"
nodejs-version: "14.16.0"
nodejs-package-manager: "yarn"
nodejs-yarn-version: v1.22.5
build-packages:

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg preserveAspectRatio="xMidYMid" version="1.1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><path d="m128 0c-70.692 0-128 57.308-128 128s57.308 128 128 128 128-57.308 128-128-57.308-128-128-128zm-51 64.268h3.334c16.733 0 16.732-5.78e-4 16.732 16.732v91.867c-1e-6 16.733 5.77e-4 16.732-16.732 16.732h-3.334c-16.733 0-16.732 5.8e-4 -16.732-16.732v-91.867c0-16.733-5.78e-4 -16.732 16.732-16.732zm44.041 0h37.537c32.178 0 52.627 32.273 52.627 63.025 0 30.752-20.627 62.307-52.627 62.307h-37.537c-5.699 0-8.5078-2.8088-8.5078-8.5078v-108.32c0-5.698 2.8088-8.5059 8.5078-8.5059z" fill="#fff" mask="url(#mask-2)"/></svg>

After

Width:  |  Height:  |  Size: 992 B

View File

@@ -8,16 +8,17 @@ import { MediaStatus } from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import type { Collection } from '../../../server/models/Collection';
import { LanguageContext } from '../../context/LanguageContext';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import StatusBadge from '../StatusBadge';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import LoadingSpinner from '../Common/LoadingSpinner';
import Modal from '../Common/Modal';
import Slider from '../Slider';
import TitleCard from '../TitleCard';
import Transition from '../Transition';
import PageTitle from '../Common/PageTitle';
import { useUser, Permission } from '../../hooks/useUser';
import useSettings from '../../hooks/useSettings';
const messages = defineMessages({
overviewunavailable: 'Overview unavailable.',
@@ -29,6 +30,10 @@ const messages = defineMessages({
requestcollection: 'Request Collection',
requestswillbecreated:
'The following titles will have requests created for them:',
request4k: 'Request 4K',
requestcollection4k: 'Request Collection in 4K',
requestswillbecreated4k:
'The following titles will have 4K requests created for them:',
requestSuccess: '<strong>{title}</strong> successfully requested!',
});
@@ -41,10 +46,14 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
}) => {
const intl = useIntl();
const router = useRouter();
const settings = useSettings();
const { addToast } = useToasts();
const { locale } = useContext(LanguageContext);
const { hasPermission } = useUser();
const [requestModal, setRequestModal] = useState(false);
const [isRequesting, setRequesting] = useState(false);
const [is4k, setIs4k] = useState(false);
const { data, error, revalidate } = useSWR<Collection>(
`/api/v1/collection/${router.query.collectionId}?language=${locale}`,
{
@@ -61,8 +70,45 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
return <Error statusCode={404} />;
}
let collectionStatus = MediaStatus.UNKNOWN;
let collectionStatus4k = MediaStatus.UNKNOWN;
if (
data.parts.every(
(part) =>
part.mediaInfo && part.mediaInfo.status === MediaStatus.AVAILABLE
)
) {
collectionStatus = MediaStatus.AVAILABLE;
} else if (
data.parts.some(
(part) =>
part.mediaInfo && part.mediaInfo.status === MediaStatus.AVAILABLE
)
) {
collectionStatus = MediaStatus.PARTIALLY_AVAILABLE;
}
if (
data.parts.every(
(part) =>
part.mediaInfo && part.mediaInfo.status4k === MediaStatus.AVAILABLE
)
) {
collectionStatus4k = MediaStatus.AVAILABLE;
} else if (
data.parts.some(
(part) =>
part.mediaInfo && part.mediaInfo.status4k === MediaStatus.AVAILABLE
)
) {
collectionStatus4k = MediaStatus.PARTIALLY_AVAILABLE;
}
const requestableParts = data.parts.filter(
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
(part) =>
!part.mediaInfo ||
part.mediaInfo[is4k ? 'status4k' : 'status'] === MediaStatus.UNKNOWN
);
const requestBundle = async () => {
@@ -73,6 +119,7 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
await axios.post<MediaRequest>('/api/v1/request', {
mediaId: part.id,
mediaType: 'movie',
is4k,
});
})
);
@@ -102,7 +149,7 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
return (
<div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover"
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})`,
@@ -123,12 +170,14 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
okText={
isRequesting
? intl.formatMessage(messages.requesting)
: intl.formatMessage(messages.request)
: intl.formatMessage(is4k ? messages.request4k : messages.request)
}
okDisabled={isRequesting}
okButtonType="primary"
onCancel={() => setRequestModal(false)}
title={intl.formatMessage(messages.requestcollection)}
title={intl.formatMessage(
is4k ? messages.requestcollection4k : messages.requestcollection
)}
iconSvg={
<svg
className="w-6 h-6"
@@ -146,13 +195,20 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
</svg>
}
>
<p>{intl.formatMessage(messages.requestswillbecreated)}</p>
<p>
{intl.formatMessage(
is4k
? messages.requestswillbecreated4k
: messages.requestswillbecreated
)}
</p>
<ul className="py-4 pl-8 list-disc">
{data.parts
.filter(
(part) =>
!part.mediaInfo ||
part.mediaInfo?.status === MediaStatus.UNKNOWN
part.mediaInfo[is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN
)
.map((part) => (
<li key={`request-part-${part.id}`}>{part.title}</li>
@@ -160,64 +216,128 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
</ul>
</Modal>
</Transition>
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
<div className="flex-shrink-0 md:mr-4">
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
<div className="lg:mr-4">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-52"
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
/>
</div>
<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.parts.every(
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
) && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{!data.parts.every(
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
) &&
data.parts.some(
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2 space-x-2">
<span className="ml-2 lg:ml-0">
<StatusBadge
status={collectionStatus}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
/>
</span>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{
type: 'or',
}
) && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.partiallyavailable)}
</Badge>
<span>
<StatusBadge
status={collectionStatus4k}
is4k
inProgress={data.parts.some(
(part) =>
(part.mediaInfo?.downloadStatus4k ?? []).length > 0
)}
/>
</span>
)}
</div>
<h1 className="text-2xl md:text-4xl">{data.name}</h1>
<span className="mt-1 text-xs md:text-base md:mt-0">
<span className="mt-1 text-xs lg:text-base lg:mt-0">
{intl.formatMessage(messages.numberofmovies, {
count: data.parts.length,
})}
</span>
</div>
<div className="flex justify-end flex-1 mt-4 md:mt-0">
{data.parts.some(
(part) =>
!part.mediaInfo || part.mediaInfo?.status === MediaStatus.UNKNOWN
) && (
<Button buttonType="primary" onClick={() => setRequestModal(true)}>
<svg
className="w-4 mr-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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{intl.formatMessage(messages.requestcollection)}
</Button>
)}
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
{hasPermission(Permission.REQUEST) &&
(collectionStatus !== MediaStatus.AVAILABLE ||
(settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' }
) &&
collectionStatus4k !== MediaStatus.AVAILABLE)) && (
<div className="mb-3 sm:mb-0">
<ButtonWithDropdown
buttonType="primary"
onClick={() => {
setRequestModal(true);
setIs4k(collectionStatus === MediaStatus.AVAILABLE);
}}
text={
<>
<svg
className="w-4 mr-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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<span>
{intl.formatMessage(
collectionStatus === MediaStatus.AVAILABLE
? messages.requestcollection4k
: messages.requestcollection
)}
</span>
</>
}
>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' }
) &&
collectionStatus !== MediaStatus.AVAILABLE &&
collectionStatus4k !== MediaStatus.AVAILABLE && (
<ButtonWithDropdown.Item
buttonType="primary"
onClick={() => {
setRequestModal(true);
setIs4k(true);
}}
>
<svg
className="w-4 mr-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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<span>
{intl.formatMessage(messages.requestcollection4k)}
</span>
</ButtonWithDropdown.Item>
)}
</ButtonWithDropdown>
</div>
)}
</div>
</div>
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">

View File

@@ -1,4 +1,4 @@
import React, { ButtonHTMLAttributes } from 'react';
import React, { ForwardedRef } from 'react';
export type ButtonType =
| 'default'
@@ -8,20 +8,44 @@ export type ButtonType =
| 'success'
| 'ghost';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
// Helper type to override types (overrides onClick)
type MergeElementProps<
T extends React.ElementType,
P extends Record<string, unknown>
> = Omit<React.ComponentProps<T>, keyof P> & P;
type ElementTypes = 'button' | 'a';
type Element<P extends ElementTypes = 'button'> = P extends 'a'
? HTMLAnchorElement
: HTMLButtonElement;
type BaseProps<P> = {
buttonType?: ButtonType;
buttonSize?: 'default' | 'lg' | 'md' | 'sm';
}
// Had to do declare this manually as typescript would assume e was of type any otherwise
onClick?: (
e: React.MouseEvent<P extends 'a' ? HTMLAnchorElement : HTMLButtonElement>
) => void;
};
const Button: React.FC<ButtonProps> = ({
buttonType = 'default',
buttonSize = 'default',
children,
className,
...props
}) => {
type ButtonProps<P extends React.ElementType> = {
as?: P;
} & MergeElementProps<P, BaseProps<P>>;
function Button<P extends ElementTypes = 'button'>(
{
buttonType = 'default',
buttonSize = 'default',
as,
children,
className,
...props
}: ButtonProps<P>,
ref?: React.Ref<Element<P>>
): JSX.Element {
const buttonStyle = [
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150',
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer',
];
switch (buttonType) {
case 'primary':
@@ -68,14 +92,30 @@ const Button: React.FC<ButtonProps> = ({
default:
buttonStyle.push('px-4 py-2 text-sm');
}
if (className) {
buttonStyle.push(className);
}
return (
<button className={buttonStyle.join(' ')} {...props}>
<span className="flex items-center">{children}</span>
</button>
);
};
export default Button;
buttonStyle.push(className ?? '');
if (as === 'a') {
return (
<a
className={buttonStyle.join(' ')}
{...(props as React.ComponentProps<'a'>)}
ref={ref as ForwardedRef<HTMLAnchorElement>}
>
<span className="flex items-center">{children}</span>
</a>
);
} else {
return (
<button
className={buttonStyle.join(' ')}
{...(props as React.ComponentProps<'button'>)}
ref={ref as ForwardedRef<HTMLButtonElement>}
>
<span className="flex items-center">{children}</span>
</button>
);
}
}
export default React.forwardRef(Button) as typeof Button;

View File

@@ -37,8 +37,8 @@ const ListView: React.FC<ListViewProps> = ({
{intl.formatMessage(messages.noresults)}
</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">
{items?.map((title) => {
<ul className="cardList">
{items?.map((title, index) => {
let titleCard: React.ReactNode;
switch (title.mediaType) {
@@ -90,22 +90,12 @@ const ListView: React.FC<ListViewProps> = ({
break;
}
return (
<li
key={title.id}
className="flex flex-col items-center col-span-1 text-center"
>
{titleCard}
</li>
);
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
})}
{isLoading &&
!isReachingEnd &&
[...Array(20)].map((_item, i) => (
<li
key={`placeholder-${i}`}
className="flex flex-col items-center col-span-1 text-center"
>
<li key={`placeholder-${i}`}>
<TitleCard.Placeholder canExpand />
</li>
))}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import ButtonWithDropdown from '../ButtonWithDropdown';
interface PlayButtonProps {
links: PlayButtonLink[];
}
export interface PlayButtonLink {
text: string;
url: string;
}
const PlayButton: React.FC<PlayButtonProps> = ({ links }) => {
if (!links || !links.length) {
return null;
}
return (
<ButtonWithDropdown
buttonType="ghost"
text={
<>
<svg
className="w-5 h-5 mr-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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{links[0].text}</span>
</>
}
onClick={() => {
window.open(links[0].url, '_blank');
}}
>
{links.length > 1 &&
links.slice(1).map((link, i) => {
return (
<ButtonWithDropdown.Item
key={`play-button-dropdown-item-${i}`}
onClick={() => {
window.open(link.url, '_blank');
}}
buttonType="ghost"
>
{link.text}
</ButtonWithDropdown.Item>
);
})}
</ButtonWithDropdown>
);
};
export default PlayButton;

View File

@@ -0,0 +1,48 @@
import Link from 'next/link';
import React, { useState } from 'react';
interface CompanyCardProps {
name: string;
image: string;
url: string;
}
const CompanyCard: React.FC<CompanyCardProps> = ({ image, url, name }) => {
const [isHovered, setHovered] = useState(false);
return (
<Link href={url}>
<a
className={`relative flex items-center justify-center h-32 w-64 sm:h-36 sm:w-72 p-8 shadow transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
isHovered
? 'bg-gray-700 scale-105 ring-gray-500'
: 'bg-gray-800 scale-100 ring-gray-700'
} rounded-xl`}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setHovered(true);
}
}}
role="link"
tabIndex={0}
>
<img
src={image}
alt={name}
className="relative z-40 max-w-full max-h-full"
/>
<div
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t z-0 ${
isHovered ? 'from-gray-800' : 'from-gray-900'
}`}
/>
</a>
</Link>
);
};
export default CompanyCard;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import type { MovieResult } from '../../../../server/models/Search';
import ListView from '../../Common/ListView';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../../Common/Header';
import PageTitle from '../../Common/PageTitle';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
const messages = defineMessages({
genreMovies: '{genre} Movies',
});
const DiscoverMovieGenre: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
firstResultData,
} = useDiscover<MovieResult, { genre: { id: number; name: string } }>(
`/api/v1/discover/movies/genre/${router.query.genreId}`
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.genreMovies, {
genre: firstResultData?.genre.name,
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovieGenre;

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