Compare commits

..

141 Commits

Author SHA1 Message Date
semantic-release-bot
3dfbb0e0a5 chore(release): 1.19.1 2021-02-06 05:14:36 +00:00
TheCatLady
726f62b9b6 fix(ui): Fix webhook URL validation regex (#864) 2021-02-06 14:08:24 +09:00
sct
81c30347f6 Merge branch 'develop' 2021-02-05 14:15:02 +00:00
sct
c0ea2bd189 ci(snap): pull latest from master when doing snap release 2021-02-05 14:14:16 +00:00
semantic-release-bot
a397893217 chore(release): 1.19.0 2021-02-05 10:32:05 +00:00
sct
12c5bbd778 Merge branch 'develop' 2021-02-05 10:25:43 +00:00
Weblate (bot)
c5be00eebf feat(lang): translations update from Weblate (#852)
* feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (645 of 645 strings)

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 (Chinese (Traditional))

Currently translated at 93.6% (604 of 645 strings)

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

Currently translated at 100.0% (645 of 645 strings)

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: Tijuco <sendtomy@protonmail.com>
Co-authored-by: TheCatLady <doris.houng@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
2021-02-05 19:21:57 +09:00
Danshil Mungur
4eae02a7e1 feat(ui): added next airing date to TV Shows (#842) 2021-02-05 18:34:32 +09:00
TheCatLady
b531a642f6 fix(ui): Dynamically generate path to config in warning message (#851) 2021-02-05 18:15:48 +09:00
Weblate (bot)
e4f9b8a984 feat(lang): translations update from Weblate (#841)
* feat(lang): translated using Weblate (French)

Currently translated at 98.4% (631 of 641 strings)

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

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

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

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

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

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

Currently translated at 93.6% (604 of 645 strings)

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

Currently translated at 93.6% (604 of 645 strings)

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

Currently translated at 93.6% (600 of 641 strings)

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

Currently translated at 93.4% (599 of 641 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 (Dutch)

Currently translated at 100.0% (641 of 641 strings)

feat(lang): translated using Weblate (Dutch)

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

Currently translated at 13.1% (84 of 641 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: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: TheCatLady <doris.houng@gmail.com>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Tijuco <linuxinmotion@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Artem <KovalevArtem.ru@gmail.com>
2021-02-05 11:44:23 +09:00
TheCatLady
7456bea2ae fix(ui): Use minimum char validation message (#850) 2021-02-05 11:38:21 +09:00
TheCatLady
38c76b55e0 fix(notif/ui): Use custom application title in notifications & sign-in page (#849) 2021-02-05 11:15:57 +09:00
TheCatLady
eedadb8884 refactor(ui): Display user avatars in request list (#848) 2021-02-05 10:35:11 +09:00
TheCatLady
3f9bfeb01a fix(ui): Add additional URL & email input validation (#843) 2021-02-05 10:12:51 +09:00
TheCatLady
b9d14a9fd0 fix(ui): Limit max width of forms & lists (#845) 2021-02-05 09:52:58 +09:00
sct
026795d4c9 fix(ui): validate application url and service external urls 2021-02-04 13:22:56 +00:00
sct
ee0a7bd8c0 fix(email): make image a link to the action url in request template
closes #834
2021-02-04 12:56:43 +00:00
sct
b658ddf5cf fix(ui): dont show bulk edit options on user list if there is only one user 2021-02-04 12:50:53 +00:00
sct
63d7e2b398 fix(ui): remove yup validation from display name on user edit page 2021-02-04 12:41:56 +00:00
sct
033ba9d41b feat: new permission to allow users to see other users requests
closes #840
2021-02-04 12:40:00 +00:00
sct
06e941171a fix(ui): prevent names from getting squished in AdvancedRequester user selector 2021-02-04 06:51:24 +00:00
sct
e270999745 fix(ui): fix tables extending outside viewport in mobile formats 2021-02-04 06:45:49 +00:00
Weblate (bot)
9e5e4c22f5 feat(lang): translations update from Weblate (#819)
* feat(lang): translated using Weblate (English)

Currently translated at 100.0% (634 of 634 strings)

feat(lang): translated using Weblate (English)

Currently translated at 100.0% (631 of 631 strings)

feat(lang): translated using Weblate (English)

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

Currently translated at 99.6% (628 of 630 strings)

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

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

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

Currently translated at 100.0% (632 of 632 strings)

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

Currently translated at 98.7% (622 of 630 strings)

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

Currently translated at 100.0% (626 of 626 strings)

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

Currently translated at 99.8% (625 of 626 strings)

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

Currently translated at 100.0% (624 of 624 strings)

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

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

Currently translated at 93.5% (593 of 634 strings)

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

Currently translated at 93.3% (589 of 631 strings)

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

Currently translated at 93.4% (589 of 630 strings)

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

Currently translated at 92.3% (578 of 626 strings)

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

Currently translated at 92.3% (578 of 626 strings)

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

Currently translated at 92.4% (577 of 624 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 (Dutch)

Currently translated at 100.0% (632 of 632 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.8% (631 of 632 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (630 of 630 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (626 of 626 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (624 of 624 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: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Tijuco <linuxinmotion@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
2021-02-04 15:31:45 +09:00
sct
fcd1dfc675 refactor(ui): adjust requested username text on RequestCard to show avatar 2021-02-04 05:01:36 +00:00
sct
b14abbfe08 build(deps): bump dependencies 2021-02-04 01:48:14 +00:00
TheCatLady
1e5f88f462 feat(docker): Check for /app/config volume mount during setup (#826) 2021-02-04 10:45:30 +09:00
TheCatLady
035534e9fe refactor(images): Use SVG logos for Radarr & Sonarr (#827) 2021-02-04 09:44:50 +09:00
TheCatLady
c97c96a30c fix(frontend): fix possible division by zero in download status (#839) 2021-02-04 08:42:24 +09:00
sct
b116281196 fix(ui): show request as option even if there are no radarr/sonarr servers 2021-02-03 23:31:22 +00:00
TheCatLady
e4a50c33f1 fix(ui): Hide 'Mark 4k as Available' button if 4k not enabled (#833) 2021-02-04 08:27:06 +09:00
sct
59150f955f feat: request as another user 2021-02-04 01:38:32 +09:00
TheCatLady
c0769d4f8f fix(lang): Add missing source strings & remove local user sign-in setting tip (#828) 2021-02-04 01:13:32 +09:00
TheCatLady
35c6bfc021 feat(ui): Add custom title functionality (#825) 2021-02-03 19:44:10 +09:00
sct
3ffd5ab0ee fix(ui): when PersonCard has no profilePath, correctly position name/role content 2021-02-03 07:56:26 +00:00
TheCatLady
f54df214af fix(lang): Edit English language strings (#820) 2021-02-03 16:46:46 +09:00
sct
2ad85d5fdc docs(api): update api schema to have editable server url + other touchups [skip ci] 2021-02-03 01:46:34 +00:00
TheCatLady
9d0d5b86aa feat(ui): Add local login setting (#817) 2021-02-03 10:16:44 +09:00
TheCatLady
b576f0734f build(docker): Optimize Docker image layers/caching & bump node to 14.15 (#776)
* refactor(docker): Combine RUN statements to reduce number of image layers

* Add --update && --no-cache to apk add command

* Combine RUN statements in first stage

* Consolidate COPY commands from BUILD_IMAGE

* Add docs & snap to .dockerignore

* Revert first COPY statement in 2nd stage

* Write committag.json in first stage & only do one COPY
Also pin base image Alpine version, and add Docker & GitHub Actions update monitoring via Dependabot

* Bump node to 14.15.4

* Change base image to node:14.15-alpine and remove Dependabot Docker monitoring

* Changes in response to PR comments

* Remove ARG/ENV statements from second build stage
2021-02-03 09:52:56 +09:00
Weblate (bot)
42295e076a feat(lang): translations update from Weblate (#791)
* feat(lang): translated using Weblate (English)

Currently translated at 100.0% (607 of 607 strings)

feat(lang): translated using Weblate (English)

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

Currently translated at 52.0% (322 of 619 strings)

feat(lang): translated using Weblate (Hungarian)

Currently translated at 28.4% (176 of 619 strings)

feat(lang): translated using Weblate (Hungarian)

Currently translated at 26.9% (167 of 619 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mo Pi <mopihu@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% (607 of 607 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 95.3% (594 of 623 strings)

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

Currently translated at 100.0% (607 of 607 strings)

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

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

Currently translated at 100.0% (619 of 619 strings)

feat(lang): translated using Weblate (French)

Currently translated at 88.1% (535 of 607 strings)

feat(lang): translated using Weblate (French)

Currently translated at 88.1% (535 of 607 strings)

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

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

Currently translated at 91.1% (569 of 624 strings)

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

Currently translated at 91.1% (564 of 619 strings)

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

Currently translated at 88.0% (545 of 619 strings)

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

Currently translated at 90.9% (552 of 607 strings)

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

Currently translated at 90.7% (551 of 607 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 67.5% (418 of 619 strings)

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

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

Currently translated at 100.0% (619 of 619 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (619 of 619 strings)

feat(lang): translated using Weblate (German)

Currently translated at 98.3% (597 of 607 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
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 97.2% (602 of 619 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (607 of 607 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: Mo Pi <mopihu@gmail.com>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Tijuco <linuxinmotion@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Zazou89 <alexandre@branget.fr>
Co-authored-by: Paul Hagedorn <panzer1119@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
2021-02-02 19:31:17 +09:00
sct
f338b34b74 ci(api-docs): update gh-pages deploy to set cname [skip ci] 2021-02-02 10:22:51 +00:00
sct
5d40ae6e22 docs: update readme with link to hosted api docs [skip ci] 2021-02-02 10:12:53 +00:00
sct
cb123cee83 build(deps): bump postcss and autoprefixer 2021-02-02 10:07:28 +00:00
sct
d957730a39 build(deps): update @types/express-session to 1.17.3 2021-02-02 10:02:21 +00:00
sct
77bf730f15 build(deps): bump dependencies 2021-02-02 09:51:56 +00:00
TheCatLady
95c2a21697 fix(ui): Don't display empty dropdown when no trailer available (#804) 2021-02-02 18:44:46 +09:00
TheCatLady
6e9ac275e1 fix(html): th elements should be nested under tr, not directly under thead (#801) 2021-02-02 18:04:13 +09:00
TheCatLady
1ccafc0ebd fix(ui): Restore original port input size (#814) 2021-02-02 17:48:38 +09:00
sct
7c192d54f4 fix(frontend): add github sponsor link to about page 2021-02-02 08:16:53 +00:00
sct
cf7ebc488d fix(frontend): use consistent spinner style on TitleCard/Plex Presets 2021-02-02 07:50:48 +00:00
TheCatLady
e34fbf72fd fix(ui): uniform-size checkboxes, vertically-aligned form labels, and fixes for other UI imperfections/inconsistencies (#737) 2021-02-02 16:20:05 +09:00
sct
bfe25d9755 docs(api): deploy api docs to github pages [skip ci] 2021-02-02 01:16:05 +00:00
sct
3052f12c91 fix(api): filter out adult content from combined credits 2021-02-02 00:45:21 +00:00
sct
a3b00c3458 fix(frontend): set 4k status on RequestItem when request is for 4k 2021-02-01 12:25:31 +00:00
sct
a7314f876e fix(frontend): correctly show 4k download tracker activity 2021-02-01 12:13:01 +00:00
sct
5b39911e02 fix(frontend): match request button color on titlecards to other request buttons 2021-02-01 12:06:37 +00:00
sct
ec44841356 fix(radarr): correctly set requested status after sending to radarr (with auto approve) 2021-02-01 12:00:41 +00:00
sct
43c989e423 docs(api): look for all api yml files [skip ci] 2021-02-01 03:44:37 +00:00
sct
9979941081 docs(api): fix stoplight.json format [skip ci] 2021-02-01 03:40:26 +00:00
sct
3a926b34b0 docs(api): add stoplight.json config [skip ci] 2021-02-01 03:33:42 +00:00
allcontributors[bot]
239f8e40c7 docs: add douglasparker as a contributor (#802) [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-01 12:26:45 +09:00
Douglas Parker
92be2ee42b docs: update LE/NGINX CSP to fix loading of resources (#797) 2021-02-01 12:26:10 +09:00
sct
d20bd530ed fix(sonarr-sync): sonarr sync will no longer set shows with no episodes to partially available
fixes #796
2021-02-01 03:18:48 +00:00
TheCatLady
72cffd74a7 feat(frontend): add TheTVDB external link (#800) 2021-02-01 12:12:06 +09:00
sct
6c437c515f fix(cache): use formatted numbers for displaying cache counts 2021-01-31 13:31:06 +00:00
sct
cfacb151b5 feat(lang): add support for Hungarian language 2021-01-31 13:20:32 +00:00
sct
996bd9f14e feat(cache): add cache table and flush cache option to settings
also increases tmdb cache times to about 6 hours (12 hours for detail requests)
2021-01-31 13:11:12 +00:00
TheCatLady
3c5ae360fd fix(lang): translate language names & change zh-Hant language code to zh-TW (#793) 2021-01-31 18:51:33 +09:00
sct
20289b5960 feat(cache): external API cache (#786) 2021-01-31 17:24:45 +09:00
Danshil Mungur
b239598e64 feat(tv): show cast for the entire show instead of only the last season (#778)
This uses TMDb's `aggregate_credits` instead of `credits` to get the show's cast for all seasons.

Fixes #775
2021-01-31 09:50:15 +09:00
TheCatLady
e2b800000d docs(readme): remove PROXY env var and correct grammar/inconsistencies (#788) 2021-01-31 00:48:47 +09:00
sct
e34482bc3f ci(snapcraft): remove --unshallow argument from fetch in Prepare step 2021-01-30 06:33:25 +00:00
sct
06261c1118 ci(snapcraft): remove --unshallow argument from fetch in Prepare step 2021-01-30 06:31:34 +00:00
semantic-release-bot
686fe6fdf8 chore(release): 1.18.0 2021-01-30 06:13:16 +00:00
sct
5bb2d75fe1 build(deps): bump dependencies 2021-01-30 06:09:13 +00:00
sct
c80a1fa261 Merge branch 'develop' 2021-01-30 06:06:49 +00:00
sct
6f3f24a1d7 ci(release): fix release workflow formatting 2021-01-30 06:02:45 +00:00
Weblate (bot)
71875efb48 feat(lang): translations update from Weblate (#727)
* feat(lang): translated using Weblate (English)

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

Currently translated at 26.6% (160 of 600 strings)

feat(lang): translated using Weblate (Hungarian)

Currently translated at 23.5% (135 of 573 strings)

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mo Pi <mopihu@gmail.com>
Co-authored-by: sct <sctsnipe@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% (600 of 600 strings)

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

Currently translated at 100.0% (573 of 573 strings)

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

Currently translated at 100.0% (551 of 551 strings)

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

Currently translated at 100.0% (545 of 545 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.8% (606 of 607 strings)

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

Currently translated at 99.6% (603 of 605 strings)

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

Currently translated at 100.0% (600 of 600 strings)

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

Currently translated at 100.0% (596 of 596 strings)

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

Currently translated at 100.0% (575 of 575 strings)

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

Currently translated at 100.0% (573 of 573 strings)

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

Currently translated at 92.2% (512 of 555 strings)

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

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

Currently translated at 86.7% (525 of 605 strings)

feat(lang): translated using Weblate (French)

Currently translated at 75.1% (448 of 596 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (573 of 573 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (555 of 555 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (551 of 551 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (551 of 551 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (545 of 545 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: TheCatLady <doris.houng@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 90.9% (552 of 607 strings)

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

Currently translated at 90.7% (549 of 605 strings)

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

Currently translated at 90.2% (546 of 605 strings)

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

Currently translated at 81.8% (488 of 596 strings)

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

Currently translated at 80.7% (481 of 596 strings)

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

Currently translated at 91.1% (497 of 545 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 (Swedish)

Currently translated at 60.5% (361 of 596 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/sv/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 43.7% (261 of 596 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/sr/
Translation: Overseerr/Overseerr Frontend

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

Currently translated at 90.7% (520 of 573 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 93.6% (520 of 555 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 (Spanish)

Currently translated at 97.4% (535 of 549 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 97.2% (530 of 545 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 98.8% (598 of 605 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (596 of 596 strings)

feat(lang): translated using Weblate (German)

Currently translated at 90.4% (520 of 575 strings)

feat(lang): translated using Weblate (German)

Currently translated at 92.8% (532 of 573 strings)

feat(lang): translated using Weblate (German)

Currently translated at 95.1% (528 of 555 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (551 of 551 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (545 of 545 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
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 99.6% (603 of 605 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (600 of 600 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (575 of 575 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (573 of 573 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 91.9% (527 of 573 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (551 of 551 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (545 of 545 strings)

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

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

Currently translated at 15.3% (88 of 573 strings)

Co-authored-by: David <david@hmnd.io>
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 (Japanese)

Currently translated at 53.1% (317 of 596 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/ja/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: TheCatLady <doris.houng@gmail.com>
Co-authored-by: Mo Pi <mopihu@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Tijuco <linuxinmotion@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Paul Hagedorn <panzer1119@gmail.com>
Co-authored-by: Kobe <kobe.boecquaert@gmail.com>
Co-authored-by: David <david@hmnd.io>
2021-01-30 14:59:44 +09:00
samwiseg0
2e724b5bd8 build(snap): Fix snap to pull from latest master [skip ci] (#783) 2021-01-30 14:53:19 +09:00
sct
136d874cba fix(sync): do not update series status if already available and no new seasons
fixes #777
2021-01-30 05:26:24 +00:00
TheCatLady
f1dd5e7e12 feat(ui): Move PROXY setting to UI (#782)
Users who have set PROXY=yes in their Docker containers will need to configure this setting in the UI and restart the container.
2021-01-30 10:46:51 +09:00
sct
67f8aef00d feat: add manual availability buttons to manage slideover
closes #672
2021-01-29 12:31:19 +00:00
sct
b5fd1d520c fix(notifications): only send a single notification when standard media becomes available
fixes #770
2021-01-29 08:13:50 +00:00
costaht
c43cd16eff Setting up Fail2ban with Overseerr (#739)
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Co-authored-by: Jakob Ankarhem <jakob.ankarhem@outlook.com>
2021-01-29 17:03:59 +09:00
sct
3812989a1c fix(sonarr-sync): correctly set series with no seasons to requested status
fixes #762
2021-01-28 10:35:42 +00:00
Jakob Ankarhem
1d7150c24e feat(media): add link to the item on plex (#735)
Co-authored-by: sct <sctsnipe@gmail.com>
2021-01-28 15:13:43 +09:00
sct
946bd2db5e fix(frontend): dont show external links unless slug is set 2021-01-28 00:47:36 +00:00
Jakob Ankarhem
0c6d4780c3 fix(radarr): return the updated data when updating radarr request (#765) 2021-01-28 08:04:31 +09:00
Jakob Ankarhem
45a277964b fix(radarr): fix request bug which made it unable to be added to radarr (#760) 2021-01-28 07:35:05 +09:00
sct
ec5fb83678 feat: Radarr & Sonarr Sync (#734) 2021-01-27 23:52:37 +09:00
sct
86efcd82c3 fix(frontend): fix server name position on plex settings page 2021-01-27 00:48:09 +00:00
TheCatLady
f05d4a0d0b fix(ui): Capitalization, punctuation, and grammar inconsistences & errors (#731) 2021-01-27 08:34:52 +09:00
Jakob Ankarhem
9342a40bbc fix(ip logging): add env var for proxy to fix ip logging on failed logins (#756) 2021-01-27 08:18:45 +09:00
Jakob Ankarhem
20ca3f2f5f feat(users): add editable usernames (#715) 2021-01-27 08:09:42 +09:00
Chris Pritchard
82ac76b054 feat: pre-populate server info from plex.tv API (#563)
Co-authored-by: sct <sctsnipe@gmail.com>
2021-01-25 16:34:21 +09:00
allcontributors[bot]
b3313a7f4d docs: add hmnd as a contributor (#740) [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-01-25 14:12:02 +09:00
TheCatLady
6e2589178b feat(auth): Add optional CSRF protection (#697)
* fix(auth): Missing CSRF middleware
Resolves LGTM alert/error for query js/missing-token-validation
More info: https://lgtm.com/rules/1506064038914/
2021-01-25 11:27:57 +09:00
David
4b0241c3b3 feat: ability to edit user settings in bulk (#597) 2021-01-25 09:29:43 +09:00
sct
2f75c4c6ae fix(lang): add missing i18n strings for notification settings 2021-01-23 10:13:10 +00:00
sct
21db3676d1 feat(notifications): add option to send notifications for auto-approved requests
closes #267
2021-01-23 09:54:43 +00:00
sct
d0c830e80d fix: show recently added series even if they are not complete 2021-01-22 16:01:46 +00:00
Jakob Ankarhem
660ada0b20 fix(permissions): use default user permissions when creating a local user (#713) 2021-01-23 00:46:41 +09:00
Jakob Ankarhem
2d31ea940a feat(login): add request ip to the failed request log (#714) 2021-01-23 00:23:07 +09:00
allcontributors[bot]
3ebb7d087f docs: add chriscpritchard as a contributor (#722) [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-01-22 23:12:38 +09:00
sct
5e352c201f fix(frontend): fixed mismatched rounded sizing on new login
also fixes log filename in temp log page

fixes #721
2021-01-22 14:08:10 +00:00
sct
e044146aa5 feat(lang): add support for Portuguese (Portugal) language 2021-01-22 14:02:02 +00:00
sct
083a74a686 feat(lang): update languages and fix merge conflict 2021-01-22 13:47:03 +00:00
Chris Pritchard
58dbdad2b2 docs(install/gentoo): updated gentoo install instructions [skip ci] (#701)
Includes the live ebuild (which installs the development version of the package)

Amended gentoo instructions now that repository is included in the list provided by gentoo

Co-authored-by: sct <ryan@sct.dev>
2021-01-22 22:32:02 +09:00
Tijuco
32f4916c4a feat(lang): translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (545 of 545 strings)

Translation: Overseerr/Overseerr Frontend
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
2021-01-22 12:23:27 +01:00
Kobe
6d0f7d4b50 feat(lang): translated using Weblate (Dutch)
Currently translated at 100.0% (545 of 545 strings)

Translation: Overseerr/Overseerr Frontend
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
2021-01-22 12:23:27 +01:00
Jakob Ankarhem
f90e43b01a refactor(login): redesign login page (#709) 2021-01-22 20:23:22 +09:00
Hosted Weblate
9bb11afc6b feat(lang): translated using Weblate (English)
Currently translated at 100.0% (534 of 534 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
2021-01-22 03:30:37 +01:00
Hosted Weblate
059995e0ef feat(lang): translated using Weblate (Dutch)
Currently translated at 100.0% (536 of 536 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (534 of 534 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.8% (533 of 534 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.8% (533 of 534 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kobe <kobe.boecquaert@gmail.com>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend
2021-01-22 03:30:37 +01:00
Hosted Weblate
c2a3e8ed52 feat(lang): translated using Weblate (German)
Currently translated at 99.8% (535 of 536 strings)

feat(lang): translated using Weblate (German)

Currently translated at 98.6% (529 of 536 strings)

feat(lang): translated using Weblate (German)

Currently translated at 99.8% (533 of 534 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (533 of 533 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
2021-01-22 03:30:37 +01:00
Hosted Weblate
4f94d227fc feat(lang): translated using Weblate (Spanish)
Currently translated at 99.0% (531 of 536 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (534 of 534 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
2021-01-22 03:30:37 +01:00
Hosted Weblate
9aa5c12164 feat(lang): translated using Weblate (Italian)
Currently translated at 98.5% (528 of 536 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 98.5% (528 of 536 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 92.7% (497 of 536 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 93.8% (501 of 534 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>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend
2021-01-22 03:30:37 +01:00
Hosted Weblate
4789583d66 feat(lang): translated using Weblate (French)
Currently translated at 99.8% (535 of 536 strings)

feat(lang): translated using Weblate (French)

Currently translated at 98.6% (529 of 536 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (534 of 534 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (534 of 534 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (534 of 534 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>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend
2021-01-22 03:30:37 +01:00
Hosted Weblate
98570c920e feat(lang): translated using Weblate (Portuguese (Brazil))
Currently translated at 99.4% (533 of 536 strings)

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

Currently translated at 100.0% (536 of 536 strings)

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

Currently translated at 100.0% (536 of 536 strings)

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

Currently translated at 98.6% (529 of 536 strings)

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

Currently translated at 100.0% (534 of 534 strings)

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

Currently translated at 100.0% (534 of 534 strings)

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

Currently translated at 100.0% (534 of 534 strings)

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

Currently translated at 92.8% (496 of 534 strings)

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

Currently translated at 92.8% (496 of 534 strings)

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

Currently translated at 89.7% (479 of 534 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: Tijuco <linuxinmotion@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend
2021-01-22 03:30:37 +01:00
Hosted Weblate
f001fb3b33 feat(lang): translated using Weblate (Portuguese (Portugal))
Currently translated at 100.0% (536 of 536 strings)

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

Currently translated at 52.0% (279 of 536 strings)

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

Currently translated at 0.0% (0 of 536 strings)

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

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marcos <silvamarcos1919@outlook.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_PT/
Translation: Overseerr/Overseerr Frontend
2021-01-22 03:30:37 +01:00
samwiseg0
e1accbcef0 build(armhf): Add libatomic1 to snap build for armhf (#720) 2021-01-22 11:30:33 +09:00
sct
ae9a1b3e94 fix(frontend): add poster not found image to request card and request list item 2021-01-22 02:06:36 +00:00
sct
0b055458d0 fix(frontend): add poster not found image to tv details page 2021-01-22 02:03:41 +00:00
sct
be650aae37 build(deps): bump dependencies 2021-01-22 02:02:22 +00:00
sct
a42f3115d3 chore: add headwind as a recommended extension for vscode [skip ci] 2021-01-22 01:53:54 +00:00
Jakob Ankarhem
2da0da826a fix(requests): handle when tvdbid is null (#657)
Co-authored-by: sct <sctsnipe@gmail.com>
2021-01-22 10:49:17 +09:00
imgbot[bot]
a3fe4e6321 refactor: [ImgBot] Optimize images (#712)
*Total -- 3,427.86kb -> 3,268.29kb (4.66%)

/public/images/radarr_logo.png -- 10.34kb -> 8.13kb (21.37%)
/public/logo.png -- 77.09kb -> 64.06kb (16.91%)
/public/os_logo_square.png -- 47.18kb -> 40.52kb (14.11%)
/public/android-chrome-512x512.png -- 78.14kb -> 70.37kb (9.94%)
/public/apple-touch-icon.png -- 25.49kb -> 23.13kb (9.28%)
/public/android-chrome-192x192.png -- 24.60kb -> 22.43kb (8.81%)
/src/assets/services/imdb.svg -- 3.89kb -> 3.63kb (6.58%)
/public/preview.jpg -- 1,658.89kb -> 1,588.79kb (4.23%)
/public/images/rotate2.jpg -- 1,090.20kb -> 1,045.92kb (4.06%)
/public/images/rotate3.jpg -- 382.15kb -> 371.60kb (2.76%)
/src/assets/bolt.svg -- 0.25kb -> 0.24kb (2.72%)
/public/favicon-32x32.png -- 2.09kb -> 2.03kb (2.71%)
/src/assets/xcircle.svg -- 0.26kb -> 0.25kb (2.62%)
/src/assets/extlogos/pushover.svg -- 3.24kb -> 3.16kb (2.44%)
/src/assets/useradd.svg -- 0.28kb -> 0.28kb (2.41%)
/src/assets/download.svg -- 0.32kb -> 0.31kb (2.15%)
/src/assets/ellipsis.svg -- 0.29kb -> 0.28kb (2.05%)
/src/assets/extlogos/slack.svg -- 1.15kb -> 1.15kb (0.59%)
/src/assets/available.svg -- 0.73kb -> 0.73kb (0.53%)
/src/assets/extlogos/discord_white.svg -- 1.30kb -> 1.29kb (0.53%)
/src/assets/requested.svg -- 0.94kb -> 0.94kb (0.52%)
/src/assets/extlogos/telegram.svg -- 0.66kb -> 0.66kb (0.44%)
/src/assets/unavailable.svg -- 1.19kb -> 1.18kb (0.41%)
/src/assets/rt_fresh.svg -- 1.22kb -> 1.22kb (0.08%)
/src/assets/services/tmdb.svg -- 2.52kb -> 2.51kb (0.08%)
/src/assets/rt_rotten.svg -- 1.43kb -> 1.43kb (0.07%)
/src/assets/rt_aud_fresh.svg -- 2.60kb -> 2.60kb (0.04%)
/src/assets/services/rt.svg -- 9.41kb -> 9.41kb (0.01%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>

Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: sct <ryan@sct.dev>
2021-01-21 19:00:57 +09:00
sct
421f4c17f0 fix(api): prevent duplicate movie requests
fixes #705
2021-01-21 09:37:47 +00:00
Mārtiņš Možeiko
e97228899a fix: lookup movie by imdbid if tmdbid does not exits for plex movie agent (#711) 2021-01-21 13:27:24 +09:00
allcontributors[bot]
2bfab5e99c docs: add Tamberlox as a contributor (#702) [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-01-20 20:39:57 +09:00
allcontributors[bot]
0483becbfa docs: add chriscpritchard as a contributor (#700) [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-01-20 19:29:03 +09:00
Chris Pritchard
3a1f6d5706 fix(build): fix sqlite3 build error (#691)
updated package.json and yarn.lock to include a version of node-gyp that works with python3 instead
of python2 for node-sqlite3
2021-01-20 19:28:10 +09:00
sct
6c1742e94c feat(frontend): add option to hide all available items from discovery (#699) 2021-01-20 18:56:45 +09:00
semantic-release-bot
6167011ee5 chore(release): 1.17.2 2021-01-20 07:27:33 +00:00
sct
bc399d95a0 Merge branch 'develop' 2021-01-20 07:21:48 +00:00
sct
e1032ff5df fix(requests): allow declined season requests to be re-requested
fixes #690
2021-01-20 05:23:42 +00:00
samwiseg0
e80920faa1 Add skip ci to discord notifications (#696) 2021-01-20 11:13:24 +09:00
samwiseg0
f7c0769deb build(release): Add snap package to the release workflow [skip ci] (#692)
* build(discord): Always send discord notification

* build(snap): Remove trigger for tags

* build(release): Add snap build to release workflow

* Remove pull request if from discord snap
2021-01-20 10:40:15 +09:00
sct
78444a9e64 fix(requests): update requests to approved when parent media is set as available
fixes #688
2021-01-19 15:10:15 +00:00
semantic-release-bot
519dd17d37 chore(release): 1.17.1 2021-01-19 10:24:57 +00:00
sct
0761593411 Merge branch 'develop' 2021-01-19 10:18:51 +00:00
sct
8927c6d2e3 fix(frontend): show auto approval on series request modal only with correct permissions
fixes #687
2021-01-19 10:17:36 +00:00
sct
81a96db2e7 ci: fix release discord notification [skip ci] 2021-01-19 07:54:55 +00:00
232 changed files with 12877 additions and 5138 deletions

View File

@@ -264,6 +264,43 @@
"code",
"translation"
]
},
{
"login": "chriscpritchard",
"name": "Chris Pritchard",
"avatar_url": "https://avatars1.githubusercontent.com/u/1839074?v=4",
"profile": "https://github.com/chriscpritchard",
"contributions": [
"code",
"doc"
]
},
{
"login": "Tamberlox",
"name": "Tamberlox",
"avatar_url": "https://avatars3.githubusercontent.com/u/56069014?v=4",
"profile": "https://github.com/Tamberlox",
"contributions": [
"translation"
]
},
{
"login": "hmnd",
"name": "David",
"avatar_url": "https://avatars.githubusercontent.com/u/12853597?v=4",
"profile": "https://hmnd.io",
"contributions": [
"code"
]
},
{
"login": "douglasparker",
"name": "Douglas Parker",
"avatar_url": "https://avatars.githubusercontent.com/u/18235822?v=4",
"profile": "https://www.douglas-parker.com",
"contributions": [
"doc"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

View File

@@ -1,15 +1,23 @@
node_modules
.next
**/*.md
**/.gitkeep
**/.vscode
.all-contributorsrc
.dockerignore
.editorconfig
.eslintrc.js
.git
.gitbook.yaml
.gitconfig
.gitignore
.github
.all-contributorsrc
.editorconfig
.next
.prettierignore
**/README.md
**/.vscode
config/db/db.sqlite3
config/db/logs/overseerr.log
Dockerfil**
**.md
Dockerfile*
docker-compose.yml
docs
LICENSE
node_modules
snap
stylelint.config.js

View File

@@ -32,6 +32,7 @@ module.exports = {
'formatjs/no-offset': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error'],
'jsx-a11y/no-onchange': 'off',
},
overrides: [
{

View File

@@ -6,3 +6,9 @@ updates:
interval: daily
time: '20:00'
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: '/'
schedule:
interval: daily
time: '20:00'
open-pull-requests-limit: 10

View File

@@ -63,6 +63,7 @@ jobs:
discord:
name: Send Discord Notification
needs: build_and_push
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status

23
.github/workflows/deploy_docs.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Deploy API Docs
on:
push:
branches:
- develop
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Generate Swagger UI
uses: Legion2/swagger-ui-action@v1
with:
output: swagger-ui
spec-file: overseerr-api.yml
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: swagger-ui
cname: api-docs.overseerr.dev

View File

@@ -7,6 +7,7 @@ on:
jobs:
test:
name: Lint & Test Build
runs-on: ubuntu-20.04
container: node:12.18-alpine
steps:
@@ -41,8 +42,67 @@ 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
- 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: 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: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1
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 }}
discord:
name: Send Discord Notification
needs: semantic-release
if: always()
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status

View File

@@ -3,8 +3,6 @@ name: Publish Snap
on:
push:
branches: [develop]
tags: [v*]
pull_request: ~
jobs:
test:
@@ -74,10 +72,10 @@ jobs:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
discord:
name: Send Discord Notification
needs: build-snap
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status

3
.gitignore vendored
View File

@@ -47,3 +47,6 @@ dist
# sqlite journal
config/db/db.sqlite3-journal
# VS Code
.vscode/launch.json

9
.stoplight.json Normal file
View File

@@ -0,0 +1,9 @@
{
"formats": {
"openapi": {
"rootDir": ".",
"include": ["**"]
}
},
"exclude": ["docs"]
}

View File

@@ -19,6 +19,9 @@
"stylelint.vscode-stylelint",
"bradlc.vscode-tailwindcss"
"bradlc.vscode-tailwindcss",
// https://marketplace.visualstudio.com/items?itemName=heybourn.headwind
"heybourn.headwind"
]
}

View File

@@ -1,3 +1,134 @@
## [1.19.1](https://github.com/sct/overseerr/compare/v1.19.0...v1.19.1) (2021-02-06)
### Bug Fixes
* **ui:** Fix webhook URL validation regex ([#864](https://github.com/sct/overseerr/issues/864)) ([726f62b](https://github.com/sct/overseerr/commit/726f62b9b69b5078e718f129e26abdf358f5cb06))
# [1.19.0](https://github.com/sct/overseerr/compare/v1.18.0...v1.19.0) (2021-02-05)
### Bug Fixes
* **api:** filter out adult content from combined credits ([3052f12](https://github.com/sct/overseerr/commit/3052f12c91b3ce86128324e3698fff61bbce3f2a))
* **cache:** use formatted numbers for displaying cache counts ([6c437c5](https://github.com/sct/overseerr/commit/6c437c515fc01b9fe4461968875e23542bae7542))
* **email:** make image a link to the action url in request template ([ee0a7bd](https://github.com/sct/overseerr/commit/ee0a7bd8c0b3a79c292b0abceb2f780f3889e49f)), closes [#834](https://github.com/sct/overseerr/issues/834)
* **frontend:** add github sponsor link to about page ([7c192d5](https://github.com/sct/overseerr/commit/7c192d54f422a5f2b55750535d2382e313f1d011))
* **frontend:** correctly show 4k download tracker activity ([a7314f8](https://github.com/sct/overseerr/commit/a7314f876ea528fdec0fb0a2adaa36a01afcdf38))
* **frontend:** fix possible division by zero in download status ([#839](https://github.com/sct/overseerr/issues/839)) ([c97c96a](https://github.com/sct/overseerr/commit/c97c96a30c50db7735f06c6d2d2f6193fb7da55e))
* **frontend:** match request button color on titlecards to other request buttons ([5b39911](https://github.com/sct/overseerr/commit/5b39911e024513fab7a62948e653cee08fd166c7))
* **frontend:** set 4k status on RequestItem when request is for 4k ([a3b00c3](https://github.com/sct/overseerr/commit/a3b00c3458b868506d4158fb24f0369fa5daefc5))
* **frontend:** use consistent spinner style on TitleCard/Plex Presets ([cf7ebc4](https://github.com/sct/overseerr/commit/cf7ebc488db33725444c428b4244d780ab9d123b))
* **html:** th elements should be nested under tr, not directly under thead ([#801](https://github.com/sct/overseerr/issues/801)) ([6e9ac27](https://github.com/sct/overseerr/commit/6e9ac275e19d56de8c7a366db970c7321f26fc8a))
* **lang:** Add missing source strings & remove local user sign-in setting tip ([#828](https://github.com/sct/overseerr/issues/828)) ([c0769d4](https://github.com/sct/overseerr/commit/c0769d4f8f2bad88e4638d8c3cbcc0414b3ef6fb))
* **lang:** Edit English language strings ([#820](https://github.com/sct/overseerr/issues/820)) ([f54df21](https://github.com/sct/overseerr/commit/f54df214af86d90ea8d7cfcd4e39022215c3568c))
* **lang:** translate language names & change zh-Hant language code to zh-TW ([#793](https://github.com/sct/overseerr/issues/793)) ([3c5ae36](https://github.com/sct/overseerr/commit/3c5ae360fd179d794a78cc918fe97a09216ca6b2))
* **notif/ui:** Use custom application title in notifications & sign-in page ([#849](https://github.com/sct/overseerr/issues/849)) ([38c76b5](https://github.com/sct/overseerr/commit/38c76b55e0039c489cb6a4a0a298aa6385406db4))
* **radarr:** correctly set requested status after sending to radarr (with auto approve) ([ec44841](https://github.com/sct/overseerr/commit/ec448413569ddc2f24bb856d29084169979f9f05))
* **sonarr-sync:** sonarr sync will no longer set shows with no episodes to partially available ([d20bd53](https://github.com/sct/overseerr/commit/d20bd530edaadc5887b0361358da80153e36505c)), closes [#796](https://github.com/sct/overseerr/issues/796)
* **ui:** Add additional URL & email input validation ([#843](https://github.com/sct/overseerr/issues/843)) ([3f9bfeb](https://github.com/sct/overseerr/commit/3f9bfeb01a67b2b587c7548b02ee826722e65c0f))
* **ui:** Don't display empty dropdown when no trailer available ([#804](https://github.com/sct/overseerr/issues/804)) ([95c2a21](https://github.com/sct/overseerr/commit/95c2a2169799d96413b47ab24506b330435643eb))
* **ui:** dont show bulk edit options on user list if there is only one user ([b658ddf](https://github.com/sct/overseerr/commit/b658ddf5cf61b2bb9b93cb1a4ca716cd75e18bb4))
* **ui:** Dynamically generate path to config in warning message ([#851](https://github.com/sct/overseerr/issues/851)) ([b531a64](https://github.com/sct/overseerr/commit/b531a642f601f4ef9bf39c2f5915402157e55372))
* **ui:** fix tables extending outside viewport in mobile formats ([e270999](https://github.com/sct/overseerr/commit/e270999745f97c2860f6a5b84e897dc6da8d6001))
* **ui:** Hide 'Mark 4k as Available' button if 4k not enabled ([#833](https://github.com/sct/overseerr/issues/833)) ([e4a50c3](https://github.com/sct/overseerr/commit/e4a50c33f105b440243885d72a9e96595a525447))
* **ui:** Limit max width of forms & lists ([#845](https://github.com/sct/overseerr/issues/845)) ([b9d14a9](https://github.com/sct/overseerr/commit/b9d14a9fd0f3c94d8267755147a87fe3b77fa2c3))
* **ui:** prevent names from getting squished in AdvancedRequester user selector ([06e9411](https://github.com/sct/overseerr/commit/06e941171a1d019fbb178624167c026f6df5271c))
* **ui:** remove yup validation from display name on user edit page ([63d7e2b](https://github.com/sct/overseerr/commit/63d7e2b39858fcb1cc0819a680eebccded7f4451))
* **ui:** Restore original port input size ([#814](https://github.com/sct/overseerr/issues/814)) ([1ccafc0](https://github.com/sct/overseerr/commit/1ccafc0ebd368d798f9571b83910336efa317e37))
* **ui:** show request as option even if there are no radarr/sonarr servers ([b116281](https://github.com/sct/overseerr/commit/b116281196c264b4ec35b07f1b4ffa717e50ade5))
* **ui:** uniform-size checkboxes, vertically-aligned form labels, and fixes for other UI imperfections/inconsistencies ([#737](https://github.com/sct/overseerr/issues/737)) ([e34fbf7](https://github.com/sct/overseerr/commit/e34fbf72fda34d69b9f25563fa81f88b3c20912a))
* **ui:** Use minimum char validation message ([#850](https://github.com/sct/overseerr/issues/850)) ([7456bea](https://github.com/sct/overseerr/commit/7456bea2ae600a28cb933278ffb310b63a474d6a))
* **ui:** validate application url and service external urls ([026795d](https://github.com/sct/overseerr/commit/026795d4c940cb4797d3e68089456a4c3defbb21))
* **ui:** when PersonCard has no profilePath, correctly position name/role content ([3ffd5ab](https://github.com/sct/overseerr/commit/3ffd5ab0ee8ffa63199d1428e37206f9b59fb7a5))
### Features
* **cache:** add cache table and flush cache option to settings ([996bd9f](https://github.com/sct/overseerr/commit/996bd9f14ed0f56767892c169b071be4f0f628d0))
* **cache:** external API cache ([#786](https://github.com/sct/overseerr/issues/786)) ([20289b5](https://github.com/sct/overseerr/commit/20289b5960a93545cdff9331a1a7b613f382e702))
* **docker:** Check for /app/config volume mount during setup ([#826](https://github.com/sct/overseerr/issues/826)) ([1e5f88f](https://github.com/sct/overseerr/commit/1e5f88f462b0c69db5f6ab8e0249a5905bc6952a))
* **frontend:** add TheTVDB external link ([#800](https://github.com/sct/overseerr/issues/800)) ([72cffd7](https://github.com/sct/overseerr/commit/72cffd74a75984ba98c456c0ec006ec378a8dcec))
* **lang:** add support for Hungarian language ([cfacb15](https://github.com/sct/overseerr/commit/cfacb151b52d08e19d2fcd603fb4bbcd78707cdf))
* **lang:** translations update from Weblate ([#791](https://github.com/sct/overseerr/issues/791)) ([42295e0](https://github.com/sct/overseerr/commit/42295e076a7579b226d57407a20cb0ba044e9ec1))
* **lang:** translations update from Weblate ([#819](https://github.com/sct/overseerr/issues/819)) ([9e5e4c2](https://github.com/sct/overseerr/commit/9e5e4c22f5b25df96f47875d599ed8685791382a))
* **lang:** translations update from Weblate ([#841](https://github.com/sct/overseerr/issues/841)) ([e4f9b8a](https://github.com/sct/overseerr/commit/e4f9b8a9848f3af00e86fc7108c823ed0584609f))
* **lang:** translations update from Weblate ([#852](https://github.com/sct/overseerr/issues/852)) ([c5be00e](https://github.com/sct/overseerr/commit/c5be00eebfd2b0e65295edbe282cbba22fffa660))
* **ui:** Add local login setting ([#817](https://github.com/sct/overseerr/issues/817)) ([9d0d5b8](https://github.com/sct/overseerr/commit/9d0d5b86aae025e4647bb664c6412d42192e2fe7))
* **ui:** added next airing date to TV Shows ([#842](https://github.com/sct/overseerr/issues/842)) ([4eae02a](https://github.com/sct/overseerr/commit/4eae02a7e14e377fd69ddd4a43774cb7e3d1855b))
* new permission to allow users to see other users requests ([033ba9d](https://github.com/sct/overseerr/commit/033ba9d41bddf6dc1c4512d8404f747e57923bca)), closes [#840](https://github.com/sct/overseerr/issues/840)
* request as another user ([59150f9](https://github.com/sct/overseerr/commit/59150f955f7003672ef19eb9d37156e93b79c97d))
* **tv:** show cast for the entire show instead of only the last season ([#778](https://github.com/sct/overseerr/issues/778)) ([b239598](https://github.com/sct/overseerr/commit/b239598e64d33b78dc5d7972878840149aff360a)), closes [#775](https://github.com/sct/overseerr/issues/775)
* **ui:** Add custom title functionality ([#825](https://github.com/sct/overseerr/issues/825)) ([35c6bfc](https://github.com/sct/overseerr/commit/35c6bfc0216bf879353b3ee546b439a06c8e6121))
# [1.18.0](https://github.com/sct/overseerr/compare/v1.17.2...v1.18.0) (2021-01-30)
### Bug Fixes
* **api:** prevent duplicate movie requests ([421f4c1](https://github.com/sct/overseerr/commit/421f4c17f0f206bbe7bfcbf2819014b8c7f55b6a)), closes [#705](https://github.com/sct/overseerr/issues/705)
* **build:** fix sqlite3 build error ([#691](https://github.com/sct/overseerr/issues/691)) ([3a1f6d5](https://github.com/sct/overseerr/commit/3a1f6d5706c8fc100e88425f3d89a26a0325af79))
* **frontend:** add poster not found image to request card and request list item ([ae9a1b3](https://github.com/sct/overseerr/commit/ae9a1b3e940ac2abf6e842d91f458daab3dd0f0d))
* **frontend:** add poster not found image to tv details page ([0b05545](https://github.com/sct/overseerr/commit/0b055458d0ddbfd4c87ebf9b0562f161fa3445a3))
* **frontend:** dont show external links unless slug is set ([946bd2d](https://github.com/sct/overseerr/commit/946bd2db5ecde0748b2e9bc5edfe7ca6000ec3d5))
* **frontend:** fix server name position on plex settings page ([86efcd8](https://github.com/sct/overseerr/commit/86efcd82c34ad6490f2899ebf6f84cdd1bffc498))
* **frontend:** fixed mismatched rounded sizing on new login ([5e352c2](https://github.com/sct/overseerr/commit/5e352c201fc2f731ca5f713ecb6901527ef354da)), closes [#721](https://github.com/sct/overseerr/issues/721)
* **ip logging:** add env var for proxy to fix ip logging on failed logins ([#756](https://github.com/sct/overseerr/issues/756)) ([9342a40](https://github.com/sct/overseerr/commit/9342a40bbc03f7fdda23e3876b3a4a81ea8532c0))
* **lang:** add missing i18n strings for notification settings ([2f75c4c](https://github.com/sct/overseerr/commit/2f75c4c6aed42a15bb47d3652272de8f852ec79f))
* **notifications:** only send a single notification when standard media becomes available ([b5fd1d5](https://github.com/sct/overseerr/commit/b5fd1d520cd2a7be6e6356a25129e93af1caf542)), closes [#770](https://github.com/sct/overseerr/issues/770)
* **permissions:** use default user permissions when creating a local user ([#713](https://github.com/sct/overseerr/issues/713)) ([660ada0](https://github.com/sct/overseerr/commit/660ada0b2025eb2c06d9054fd0a7b5a632af6af2))
* **radarr:** fix request bug which made it unable to be added to radarr ([#760](https://github.com/sct/overseerr/issues/760)) ([45a2779](https://github.com/sct/overseerr/commit/45a277964b0c39346d7216873812e0ebe505cb79))
* **radarr:** return the updated data when updating radarr request ([#765](https://github.com/sct/overseerr/issues/765)) ([0c6d478](https://github.com/sct/overseerr/commit/0c6d4780c355ffe1a951268fb6949491d435bbf1))
* **requests:** handle when tvdbid is null ([#657](https://github.com/sct/overseerr/issues/657)) ([2da0da8](https://github.com/sct/overseerr/commit/2da0da826ae1d73467bc8a671fda7cc5ca1f14c9))
* **sonarr-sync:** correctly set series with no seasons to requested status ([3812989](https://github.com/sct/overseerr/commit/3812989a1ce1e07d4af09149008043a6e2e94060)), closes [#762](https://github.com/sct/overseerr/issues/762)
* **sync:** do not update series status if already available and no new seasons ([136d874](https://github.com/sct/overseerr/commit/136d874cba37babf9c0670844b002871710e6d99)), closes [#777](https://github.com/sct/overseerr/issues/777)
* **ui:** Capitalization, punctuation, and grammar inconsistences & errors ([#731](https://github.com/sct/overseerr/issues/731)) ([f05d4a0](https://github.com/sct/overseerr/commit/f05d4a0d0b42905fcaee49b2471bb1f4ee77fffe))
* lookup movie by imdbid if tmdbid does not exits for plex movie agent ([#711](https://github.com/sct/overseerr/issues/711)) ([e972288](https://github.com/sct/overseerr/commit/e97228899a5936b2525c8060abfa14b5ce31658d))
* show recently added series even if they are not complete ([d0c830e](https://github.com/sct/overseerr/commit/d0c830e80d389f9e0f48a9b83659331f54630d03))
### Features
* **lang:** translated using Weblate (Dutch) ([059995e](https://github.com/sct/overseerr/commit/059995e0ef3370a3192bd386fa6875ca0f58690a))
* **lang:** translated using Weblate (French) ([4789583](https://github.com/sct/overseerr/commit/4789583d66305ac7b3d393659b2f3604c0acc576))
* **lang:** translations update from Weblate ([#727](https://github.com/sct/overseerr/issues/727)) ([71875ef](https://github.com/sct/overseerr/commit/71875efb48246dbb0139ad15a4261a5661fcfe17))
* **lang:** update languages and fix merge conflict ([083a74a](https://github.com/sct/overseerr/commit/083a74a686d202cce5775bf9752caaa9a626cf45))
* **ui:** Move PROXY setting to UI ([#782](https://github.com/sct/overseerr/issues/782)) ([f1dd5e7](https://github.com/sct/overseerr/commit/f1dd5e7e12c1f602449c4769173dbce71e3569d0))
* add manual availability buttons to manage slideover ([67f8aef](https://github.com/sct/overseerr/commit/67f8aef00d98c834b60cb6152ccd5cb7b5709d12)), closes [#672](https://github.com/sct/overseerr/issues/672)
* **media:** add link to the item on plex ([#735](https://github.com/sct/overseerr/issues/735)) ([1d7150c](https://github.com/sct/overseerr/commit/1d7150c24ec5ad347093889bfceab61b664900d5))
* Radarr & Sonarr Sync ([#734](https://github.com/sct/overseerr/issues/734)) ([ec5fb83](https://github.com/sct/overseerr/commit/ec5fb836785855eb4846fd33b49faeb94c40506a))
* **frontend:** add option to hide all available items from discovery ([#699](https://github.com/sct/overseerr/issues/699)) ([6c1742e](https://github.com/sct/overseerr/commit/6c1742e94ccfc6c13cf1d25fd9e893ee1f431aae))
* **lang:** add support for Portuguese (Portugal) language ([e044146](https://github.com/sct/overseerr/commit/e044146aa55109a1eccfde9650b26beb0d5ec9a6))
* **lang:** translated using Weblate (Dutch) ([6d0f7d4](https://github.com/sct/overseerr/commit/6d0f7d4b50370c420c1017f32d48313074543743))
* **lang:** translated using Weblate (Italian) ([9aa5c12](https://github.com/sct/overseerr/commit/9aa5c121644518c1fbb308a487c26d8998bb5a36))
* **lang:** translated using Weblate (Portuguese (Portugal)) ([f001fb3](https://github.com/sct/overseerr/commit/f001fb3b33d4fb749acb70c45b8a55a5bbef570c))
* **lang:** translated using Weblate (Spanish) ([4f94d22](https://github.com/sct/overseerr/commit/4f94d227fc3096bcb8a1e5cf12fe9222d6c6b711))
* **login:** add request ip to the failed request log ([#714](https://github.com/sct/overseerr/issues/714)) ([2d31ea9](https://github.com/sct/overseerr/commit/2d31ea940ac0a1a84d2150743798b41ff6490317))
* **users:** add editable usernames ([#715](https://github.com/sct/overseerr/issues/715)) ([20ca3f2](https://github.com/sct/overseerr/commit/20ca3f2f5fcf4a9eb0d6a8be671bb4fb1f5e6178))
* pre-populate server info from plex.tv API ([#563](https://github.com/sct/overseerr/issues/563)) ([82ac76b](https://github.com/sct/overseerr/commit/82ac76b0540ba1133cb5384744d2499c2488a4e8))
* **auth:** Add optional CSRF protection ([#697](https://github.com/sct/overseerr/issues/697)) ([6e25891](https://github.com/sct/overseerr/commit/6e2589178b99f8f32f0ded9a7cfd9921c33e9b60))
* ability to edit user settings in bulk ([#597](https://github.com/sct/overseerr/issues/597)) ([4b0241c](https://github.com/sct/overseerr/commit/4b0241c3b34d4229f928c21defb10a1c051264d1))
* **lang:** translated using Weblate (English) ([9bb11af](https://github.com/sct/overseerr/commit/9bb11afc6b4a109ae1e14d41c9fe2b71f19c470a))
* **lang:** translated using Weblate (German) ([c2a3e8e](https://github.com/sct/overseerr/commit/c2a3e8ed5243925dce991ec7995ae831702dbc7b))
* **lang:** translated using Weblate (Portuguese (Brazil)) ([32f4916](https://github.com/sct/overseerr/commit/32f4916c4a926097f31ed472aee031536b847bb7))
* **lang:** translated using Weblate (Portuguese (Brazil)) ([98570c9](https://github.com/sct/overseerr/commit/98570c920e4904a594bb7464161b985094958f84))
* **notifications:** add option to send notifications for auto-approved requests ([21db367](https://github.com/sct/overseerr/commit/21db3676d1464b63384b04c0c2926cb2a6252e9b)), closes [#267](https://github.com/sct/overseerr/issues/267)
## [1.17.2](https://github.com/sct/overseerr/compare/v1.17.1...v1.17.2) (2021-01-20)
### Bug Fixes
* **requests:** allow declined season requests to be re-requested ([e1032ff](https://github.com/sct/overseerr/commit/e1032ff5dfac4a8c9d4da9cf2788c19822343ad9)), closes [#690](https://github.com/sct/overseerr/issues/690)
* **requests:** update requests to approved when parent media is set as available ([78444a9](https://github.com/sct/overseerr/commit/78444a9e643829823162389dee60cca70da56bff)), closes [#688](https://github.com/sct/overseerr/issues/688)
## [1.17.1](https://github.com/sct/overseerr/compare/v1.17.0...v1.17.1) (2021-01-19)
### Bug Fixes
* **frontend:** show auto approval on series request modal only with correct permissions ([8927c6d](https://github.com/sct/overseerr/commit/8927c6d2e39dbda2b1121095a7273f5cab1c9b74)), closes [#687](https://github.com/sct/overseerr/issues/687)
# [1.17.0](https://github.com/sct/overseerr/compare/v1.16.0...v1.17.0) (2021-01-19)

View File

@@ -1,4 +1,4 @@
FROM node:12.18-alpine AS BUILD_IMAGE
FROM node:14.15-alpine AS BUILD_IMAGE
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
@@ -11,25 +11,23 @@ RUN yarn --frozen-lockfile && \
# remove development dependencies
RUN yarn install --production --ignore-scripts --prefer-offline
RUN yarn cache clean
FROM node:12.18-alpine
RUN rm -rf src && \
rm -rf server
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
RUN apk add tzdata
COPY . /app
WORKDIR /app
# copy from build image
COPY --from=BUILD_IMAGE /app/dist ./dist
COPY --from=BUILD_IMAGE /app/.next ./.next
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:14.15-alpine
RUN apk add --no-cache tzdata
# copy from build image
COPY --from=BUILD_IMAGE /app /app
WORKDIR /app
CMD yarn start
EXPOSE 5055

View File

@@ -16,21 +16,21 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-28-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-32-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**, **Radarr** and **Plex**!
**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!
- Integrates easily with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come!
- Syncs to your Plex library to know what titles you already have.
- 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.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests.
- Granular permission system
- Mobile friendly design, for when you need to approve requests on the go!
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
- Granular permission system.
- Mobile-friendly design, for when you need to approve requests on the go!
## In Development
@@ -46,13 +46,13 @@
## Getting Started
Check out our documenation for steps on how to install and run Overseerr:
Check out our documentation for instructions on how to install and run Overseerr:
https://docs.overseerr.dev/getting-started/installation
## Running Overseerr
Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per:
Currently, Overseerr is primarily distributed as Docker images. If you have Docker, you can run Overseerr with:
```
docker run -d \
@@ -66,7 +66,7 @@ docker run -d \
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps.
⚠️ 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 image **sctx/overseerr:develop** instead! ⚠️
⚠️ 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! ⚠️
## Preview
@@ -77,11 +77,13 @@ After running Overseerr for the first time, configure it by visiting the web UI
- Check out the [Overseerr Documentation](https://docs.overseerr.dev/) before asking for help. Your question might already be in the [FAQ](https://docs.overseerr.dev/support/faq).
- You can get support on [Discord](https://discord.gg/PkCWJSeCk7).
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions).
- Bugs/Feature Requests can be opened via a [GitHub issue](https://github.com/sct/overseerr/issues).
- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/sct/overseerr/issues).
## API Documentation
Full API documentation will soon be published automatically and available outside of running the app. Currently, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs
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
## Community
@@ -139,6 +141,12 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<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>
</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>
</tr>
</table>
<!-- markdownlint-restore -->

View File

@@ -18,4 +18,5 @@
## Extending Overseerr
- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
* [Fail2ban Filter](extending-overseerr/fail2ban.md)

View File

@@ -0,0 +1,14 @@
# Fail2ban Filter
{% hint style="warning" %}
If you are running Overseerr behind a reverse proxy, make sure that the **Enable Proxy Support** setting is **enabled**.
{% endhint %}
To use Fail2ban with Overseerr, create a new file named `overseerr.local` in your Fail2ban `filter.d` directory with the following filter definition:
```
[Definition]
failregex = .*\[info\]\[Auth\]\: Failed login 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.

View File

@@ -10,7 +10,7 @@ Base URLs cannot be configured in Overseerr. With this limitation, only subdomai
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.
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:
@@ -115,13 +115,15 @@ server {
# HTTP Strict Transport Security
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Reduce XSS risks (Content-Security-Policy) - uncomment to use and add URLs whenever necessary
# add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
# add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self' 'unsafe-inline'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
# Prevent some categories of XSS attacks (X-XSS-Protection)
add_header X-XSS-Protection "1; mode=block" always;
# Provide clickjacking protection (X-Frame-Options)
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent Sniff Mimetype (X-Content-Type-Options)
add_header X-Content-Type-Options "nosniff" always;
# Tell crawling bots to not index the site
add_header X-Robots-Tag "noindex, nofollow" always;
access_log /var/log/nginx/overseerr.example.com-access.log;
error_log /var/log/nginx/overseerr.example.com-error.log;
@@ -131,4 +133,3 @@ server {
}
}
```

View File

@@ -121,26 +121,49 @@ This version can break any moment. Be prepared to troubleshoot any issues that a
{% tabs %}
{% tab title="Gentoo" %}
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay)
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay).
This is now included in the list of [Gentoo repositories](https://overlays.gentoo.org/), so can be easily enabled with `eselect repository`
Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed.
To enable using eselect repository, run:
**To enable:**
To enable using `eselect repository`, run:
```bash
eselect repository add overseerr-overlay git https://github.com/chriscpritchard/overseerr-overlay.git
eselect repository enable overseerr-overlay
```
**To install:**
Once complete, you can just run:
```bash
emerge www-apps/overseerr
```
**To install the development build:**
A live ebuild (`=www-apps/overseerr-9999`) is also available. To use this, you will need to modify accept_keywords for this package:
```bash
emerge --autounmask --autounmask-write "=www-apps/overseerr-9999"
```
Once installed, you will not be notified of updates, so you can update with:
```bash
emerge @live-rebuild
```
or use `app-portage/smart-live-rebuild`
{% hint style="danger" %}
This version can break any moment. Be prepared to troubleshoot any issues that arise!
{% endhint %}
{% endtab %}
{% tab title="Swizzin" %}
The installation is not implemented via docker, but barebones. The latest released version of overseerr will be used.
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.
To install, run the following:

View File

@@ -36,7 +36,7 @@ The most secure method, but also the most inconvenient, is to set up a VPN tunne
### Some media is missing from Overseerr that I know is in Plex!
**A:** Overseerr supports the new Plex Movie, Legacy Plex Movie, TheTVDB agent, and the TMDb agent. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library.
**A:** Overseerr supports the new Plex Movie, legacy Plex Movie, TheTVDB, and TMDb agents. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library.
**Troubleshooting Steps:**
@@ -55,8 +55,8 @@ Perform these steps to verify the media item has a guid Overseerr can match.
**Examples:**
1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"`
2. The new Plex Movie agent `<Guid id="tmdb://464052"/>`
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"`
@@ -68,7 +68,7 @@ Perform these steps to verify the media item has a guid Overseerr can match.
### 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 -&gt; General Settings** page beforehand.
### Can I create local users in Overseerr?

View File

@@ -11,7 +11,7 @@ Overseerr already supports a good number of notification agents, such as **Disco
- Pushover
- [Webhooks](./webhooks.md)
## Setting up Notifications
## 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.

View File

@@ -42,8 +42,8 @@ These variables are usually the target user of the notification.
These variables are only included in media related notifications, such as requests.
- `{{media_type}}` Media type. Either `movie` or `tv`.
- `{{media_tmdbid}}` Media's TMDB ID.
- `{{media_imdbid}}` Media's IMDB ID.
- `{{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`)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "overseerr",
"version": "1.17.0",
"version": "1.19.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",
@@ -17,6 +17,8 @@
},
"license": "MIT",
"dependencies": {
"@headlessui/react": "^0.2.0-da179ca",
"@supercharge/request-ip": "^1.1.2",
"@svgr/webpack": "^5.5.0",
"ace-builds": "^1.4.12",
"axios": "^0.21.1",
@@ -25,25 +27,28 @@
"bowser": "^2.11.0",
"connect-typeorm": "^1.1.4",
"cookie-parser": "^1.4.5",
"csurf": "^1.11.0",
"email-templates": "^8.0.3",
"express": "^4.17.1",
"express-openapi-validator": "^4.10.8",
"express-openapi-validator": "^4.10.11",
"express-session": "^1.17.1",
"formik": "^2.2.6",
"gravatar-url": "^3.1.0",
"intl": "^1.2.5",
"lodash": "^4.17.20",
"next": "10.0.3",
"node-schedule": "^1.3.2",
"node-cache": "^5.1.2",
"node-schedule": "^2.0.0",
"nodemailer": "^6.4.17",
"nookies": "^2.5.1",
"nookies": "^2.5.2",
"plex-api": "^5.3.1",
"pug": "^3.0.0",
"react": "17.0.1",
"react-ace": "^9.2.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.10.16",
"react-intl": "^5.12.0",
"react-markdown": "^5.0.3",
"react-spring": "^8.0.27",
"react-toast-notifications": "^2.4.0",
@@ -54,7 +59,7 @@
"secure-random-password": "^0.2.2",
"sqlite3": "^5.0.0",
"swagger-ui-express": "^4.1.6",
"swr": "^0.4.0",
"swr": "^0.4.1",
"typeorm": "^0.2.30",
"uuid": "^8.3.2",
"winston": "^3.3.3",
@@ -64,7 +69,7 @@
"yup": "^0.32.8"
},
"devDependencies": {
"@babel/cli": "^7.12.10",
"@babel/cli": "^7.12.13",
"@commitlint/cli": "^11.0.0",
"@commitlint/config-conventional": "^11.0.0",
"@semantic-release/changelog": "^5.0.1",
@@ -77,34 +82,35 @@
"@types/bcrypt": "^3.0.0",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/email-templates": "^8.0.0",
"@types/csurf": "^1.11.0",
"@types/email-templates": "^8.0.1",
"@types/express": "^4.17.11",
"@types/express-session": "^1.17.0",
"@types/lodash": "^4.14.167",
"@types/node": "^14.14.21",
"@types/express-session": "^1.17.3",
"@types/lodash": "^4.14.168",
"@types/node": "^14.14.24",
"@types/node-schedule": "^1.3.1",
"@types/nodemailer": "^6.4.0",
"@types/react": "^17.0.0",
"@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0",
"@types/react-toast-notifications": "^2.4.0",
"@types/react-transition-group": "^4.4.0",
"@types/secure-random-password": "^0.2.0",
"@types/swagger-ui-express": "^4.1.2",
"@types/uuid": "^8.3.0",
"@types/xml2js": "^0.4.7",
"@types/xml2js": "^0.4.8",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.11",
"@typescript-eslint/eslint-plugin": "^4.13.0",
"@typescript-eslint/parser": "^4.13.0",
"autoprefixer": "^9",
"@typescript-eslint/eslint-plugin": "^4.14.2",
"@typescript-eslint/parser": "^4.14.2",
"autoprefixer": "^10.2.4",
"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.18.0",
"eslint-config-prettier": "^7.1.0",
"eslint-plugin-formatjs": "^2.10.3",
"eslint": "^7.19.0",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-formatjs": "^2.12.0",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
@@ -113,15 +119,18 @@
"husky": "^4.3.8",
"lint-staged": "^10.5.3",
"nodemon": "^2.0.7",
"postcss": "^7",
"postcss": "^8.2.4",
"postcss-preset-env": "^6.7.0",
"prettier": "^2.2.1",
"semantic-release": "^17.3.3",
"semantic-release": "^17.3.7",
"semantic-release-docker": "^2.2.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
"ts-node": "^9.1.1",
"typescript": "^4.1.3"
},
"resolutions": {
"sqlite3/node-gyp": "^5.1.0"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +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>

After

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1 @@
<svg height="216.9" viewBox="0 0 216.7 216.9" width="216.7" xmlns="http://www.w3.org/2000/svg"> <path clip-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" fill="#EEE" fill-rule="evenodd"/> <path clip-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" fill="#3A3F51" fill-rule="evenodd"/> <g clip-rule="evenodd"> <path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/> <path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/> <path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/> </g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -8,11 +8,13 @@ const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapp
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
const MAPPING_URL =
'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml';
const LOCAL_PATH = path.join(__dirname, '../../config/anime-list.xml');
const LOCAL_PATH = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/anime-list.xml`
: path.join(__dirname, '../../config/anime-list.xml');
const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g);
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to tvdb/tmdb IDs
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDb IDs
// https://github.com/Anime-Lists/anime-lists/
interface AnimeMapping {
@@ -125,7 +127,7 @@ class AnimeListMapping {
}
} else {
// some movies do not have mapping-list, so map episode 1,2,3,..to movies
// movies must have imdbid or tmdbid
// movies must have imdbId or tmdbId
const hasImdb = imdbIds.length > 1 || imdbIds[0] !== undefined;
if ((hasImdb || tmdbId) && anime.$.defaulttvdbseason === '0') {
if (!this.specials[tvdbId]) {

102
server/api/externalapi.ts Normal file
View File

@@ -0,0 +1,102 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
// 10 seconds default rolling buffer (in ms)
const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
}
class ExternalAPI {
protected axios: AxiosInstance;
private baseUrl: string;
private cache?: NodeCache;
constructor(
baseUrl: string,
params: Record<string, unknown>,
options: ExternalAPIOptions = {}
) {
this.axios = axios.create({
baseURL: baseUrl,
params,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
});
this.baseUrl = baseUrl;
this.cache = options.nodeCache;
}
protected async get<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
protected async getRolling<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
const keyTtl = this.cache?.getTtl(cacheKey) ?? 0;
// If the item has passed our rolling check, fetch again in background
if (
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER
) {
this.axios.get<T>(endpoint, config).then((response) => {
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
});
}
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
private serializeCacheKey(
endpoint: string,
params?: Record<string, unknown>
) {
if (!params) {
return `${this.baseUrl}${endpoint}`;
}
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
}
}
export default ExternalAPI;

View File

@@ -1,5 +1,5 @@
import NodePlexAPI from 'plex-api';
import { getSettings } from '../lib/settings';
import { getSettings, PlexSettings } from '../lib/settings';
export interface PlexLibraryItem {
ratingKey: string;
@@ -80,13 +80,26 @@ interface PlexMetadataResponse {
class PlexAPI {
private plexClient: NodePlexAPI;
constructor({ plexToken }: { plexToken?: string }) {
constructor({
plexToken,
plexSettings,
timeout,
}: {
plexToken?: string;
plexSettings?: PlexSettings;
timeout?: number;
}) {
const settings = getSettings();
let settingsPlex: PlexSettings | undefined;
plexSettings
? (settingsPlex = plexSettings)
: (settingsPlex = getSettings().plex);
this.plexClient = new NodePlexAPI({
hostname: settings.plex.ip,
port: settings.plex.port,
https: settings.plex.useSsl,
hostname: settingsPlex.ip,
port: settingsPlex.port,
https: settingsPlex.useSsl,
timeout: timeout,
token: plexToken,
authenticator: {
authenticate: (
@@ -105,12 +118,13 @@ class PlexAPI {
options: {
identifier: settings.clientId,
product: 'Overseerr',
deviceName: 'Overseerr',
deviceName: settings.main.applicationTitle,
platform: 'Overseerr',
},
});
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
public async getStatus() {
return await this.plexClient.query('/');
}

View File

@@ -1,5 +1,6 @@
import axios, { AxiosInstance } from 'axios';
import xml2js from 'xml2js';
import { PlexDevice } from '../interfaces/api/plexInterfaces';
import { getSettings } from '../lib/settings';
import logger from '../logger';
@@ -29,6 +30,45 @@ interface PlexUser {
entitlements: string[];
}
interface ConnectionResponse {
$: {
protocol: string;
address: string;
port: string;
uri: string;
local: string;
};
}
interface DeviceResponse {
$: {
name: string;
product: string;
productVersion: string;
platform: string;
platformVersion: string;
device: string;
clientIdentifier: string;
createdAt: string;
lastSeenAt: string;
provides: string;
owned: string;
accessToken?: string;
publicAddress?: string;
httpsRequired?: string;
synced?: string;
relay?: string;
dnsRebindingProtection?: string;
natLoopbackSupported?: string;
publicAddressMatches?: string;
presence?: string;
ownerID?: string;
home?: string;
sourceTitle?: string;
};
Connection: ConnectionResponse[];
}
interface ServerResponse {
$: {
id: string;
@@ -87,6 +127,62 @@ class PlexTvAPI {
});
}
public async getDevices(): Promise<PlexDevice[]> {
try {
const devicesResp = await this.axios.get(
'/api/resources?includeHttps=1',
{
transformResponse: [],
responseType: 'text',
}
);
const parsedXml = await xml2js.parseStringPromise(
devicesResp.data as DeviceResponse
);
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
name: pxml.$.name,
product: pxml.$.product,
productVersion: pxml.$.productVersion,
platform: pxml.$?.platform,
platformVersion: pxml.$?.platformVersion,
device: pxml.$?.device,
clientIdentifier: pxml.$.clientIdentifier,
createdAt: new Date(parseInt(pxml.$?.createdAt, 10) * 1000),
lastSeenAt: new Date(parseInt(pxml.$?.lastSeenAt, 10) * 1000),
provides: pxml.$.provides.split(','),
owned: pxml.$.owned == '1' ? true : false,
accessToken: pxml.$?.accessToken,
publicAddress: pxml.$?.publicAddress,
publicAddressMatches:
pxml.$?.publicAddressMatches == '1' ? true : false,
httpsRequired: pxml.$?.httpsRequired == '1' ? true : false,
synced: pxml.$?.synced == '1' ? true : false,
relay: pxml.$?.relay == '1' ? true : false,
dnsRebindingProtection:
pxml.$?.dnsRebindingProtection == '1' ? true : false,
natLoopbackSupported:
pxml.$?.natLoopbackSupported == '1' ? true : false,
presence: pxml.$?.presence == '1' ? true : false,
ownerID: pxml.$?.ownerID,
home: pxml.$?.home == '1' ? true : false,
sourceTitle: pxml.$?.sourceTitle,
connection: pxml?.Connection?.map((conn: ConnectionResponse) => ({
protocol: conn.$.protocol,
address: conn.$.address,
port: parseInt(conn.$.port, 10),
uri: conn.$.uri,
local: conn.$.local == '1' ? true : false,
})),
}));
} catch (e) {
logger.error('Something went wrong getting the devices from plex.tv', {
label: 'Plex.tv API',
errorMessage: e.message,
});
throw new Error('Invalid auth token');
}
}
public async getUser(): Promise<PlexUser> {
try {
const account = await this.axios.get<PlexAccountResponse>(
@@ -96,7 +192,7 @@ class PlexTvAPI {
return account.data.user;
} catch (e) {
logger.error(
`Something went wrong getting the account from plex.tv: ${e.message}`,
`Something went wrong while getting the account from plex.tv: ${e.message}`,
{ label: 'Plex.tv API' }
);
throw new Error('Invalid auth token');

View File

@@ -1,5 +1,7 @@
import Axios, { AxiosInstance } from 'axios';
import cacheManager from '../lib/cache';
import { RadarrSettings } from '../lib/settings';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface RadarrMovieOptions {
title: string;
@@ -13,12 +15,13 @@ interface RadarrMovieOptions {
searchNow?: boolean;
}
interface RadarrMovie {
export interface RadarrMovie {
id: number;
title: string;
isAvailable: boolean;
monitored: boolean;
tmdbId: number;
imdbId: string;
titleSlug: string;
folderName: string;
path: string;
@@ -45,15 +48,49 @@ export interface RadarrProfile {
name: string;
}
class RadarrAPI {
private axios: AxiosInstance;
interface QueueItem {
movieId: number;
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
downloadId: string;
protocol: string;
downloadClient: string;
indexer: string;
id: number;
}
interface QueueResponse {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: QueueItem[];
}
class RadarrAPI extends ExternalAPI {
static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string {
return `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`;
}
constructor({ url, apiKey }: { url: string; apiKey: string }) {
this.axios = Axios.create({
baseURL: url,
params: {
super(
url,
{
apikey: apiKey,
},
});
{
nodeCache: cacheManager.getCache('radarr').data,
}
);
}
public getMovies = async (): Promise<RadarrMovie[]> => {
@@ -76,8 +113,89 @@ class RadarrAPI {
}
};
public addMovie = async (options: RadarrMovieOptions): Promise<boolean> => {
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
try {
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
params: {
term: `tmdb:${id}`,
},
});
if (!response.data[0]) {
throw new Error('Movie not found');
}
return response.data[0];
} catch (e) {
logger.error('Error retrieving movie by TMDb ID', {
label: 'Radarr API',
message: e.message,
});
throw new Error('Movie not found');
}
}
public addMovie = async (
options: RadarrMovieOptions
): Promise<RadarrMovie> => {
try {
const movie = await this.getMovieByTmdbId(options.tmdbId);
if (movie.downloaded) {
logger.info(
'Title already exists and is available. Skipping add and returning success',
{
label: 'Radarr',
}
);
return movie;
}
// movie exists in radarr but is neither downloaded nor monitored
if (movie.id && !movie.monitored) {
const response = await this.axios.put<RadarrMovie>(`/movie`, {
...movie,
title: options.title,
qualityProfileId: options.qualityProfileId,
profileId: options.profileId,
titleSlug: options.tmdbId.toString(),
minimumAvailability: options.minimumAvailability,
tmdbId: options.tmdbId,
year: options.year,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
addOptions: {
searchForMovie: options.searchNow,
},
});
if (response.data.monitored) {
logger.info(
'Found existing title in Radarr and set it to monitored. Returning success',
{ label: 'Radarr' }
);
logger.debug('Radarr update details', {
label: 'Radarr',
movie: response.data,
});
return response.data;
} else {
logger.error('Failed to update existing movie in Radarr', {
label: 'Radarr',
options,
});
throw new Error('Failed to update existing movie in Radarr');
}
}
if (movie.id) {
logger.info(
'Movie is already monitored in Radarr. Skipping add and returning success',
{ label: 'Radarr' }
);
return movie;
}
const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title,
qualityProfileId: options.qualityProfileId,
@@ -104,9 +222,9 @@ class RadarrAPI {
label: 'Radarr',
options,
});
return false;
throw new Error('Failed to add movie to Radarr');
}
return true;
return response.data;
} catch (e) {
logger.error(
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
@@ -117,18 +235,19 @@ class RadarrAPI {
response: e?.response?.data,
}
);
if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') {
return true;
}
return false;
throw new Error('Failed to add movie to Radarr');
}
};
public getProfiles = async (): Promise<RadarrProfile[]> => {
try {
const response = await this.axios.get<RadarrProfile[]>(`/profile`);
const data = await this.getRolling<RadarrProfile[]>(
`/profile`,
undefined,
3600
);
return response.data;
return data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`);
}
@@ -136,13 +255,27 @@ class RadarrAPI {
public getRootFolders = async (): Promise<RadarrRootFolder[]> => {
try {
const response = await this.axios.get<RadarrRootFolder[]>(`/rootfolder`);
const data = await this.getRolling<RadarrRootFolder[]>(
`/rootfolder`,
undefined,
3600
);
return response.data;
return data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`);
}
};
public getQueue = async (): Promise<QueueItem[]> => {
try {
const response = await this.axios.get<QueueResponse>(`/queue`);
return response.data.records;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
}
};
}
export default RadarrAPI;

View File

@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from 'axios';
import cacheManager from '../lib/cache';
import ExternalAPI from './externalapi';
interface RTMovieOldSearchResult {
id: number;
@@ -55,17 +56,19 @@ export interface RTRating {
* Unfortunately, we need to do it by searching for the movie name, so it's
* not always accurate.
*/
class RottenTomatoes {
private axios: AxiosInstance;
class RottenTomatoes extends ExternalAPI {
constructor() {
this.axios = axios.create({
baseURL: 'https://www.rottentomatoes.com/api/private',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
super(
'https://www.rottentomatoes.com/api/private',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('rt').data,
}
);
}
/**
@@ -85,33 +88,30 @@ class RottenTomatoes {
year: number
): Promise<RTRating | null> {
try {
const response = await this.axios.get<RTMovieSearchResponse>(
'/v1.0/movies',
{
params: { q: name },
}
);
const data = await this.get<RTMovieSearchResponse>('/v1.0/movies', {
params: { q: name },
});
// First, attempt to match exact name and year
let movie = response.data.movies.find(
let movie = data.movies.find(
(movie) => movie.year === year && movie.title === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = response.data.movies.find(
movie = data.movies.find(
(movie) => movie.year === year && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = response.data.movies.find((movie) => movie.year === year);
movie = data.movies.find((movie) => movie.year === year);
}
// One last try, try exact name match only
if (!movie) {
movie = response.data.movies.find((movie) => movie.title === name);
movie = data.movies.find((movie) => movie.title === name);
}
if (!movie) {
@@ -139,19 +139,14 @@ class RottenTomatoes {
year?: number
): Promise<RTRating | null> {
try {
const response = await this.axios.get<RTMultiSearchResponse>(
'/v2.0/search/',
{
params: { q: name, limit: 10 },
}
);
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
params: { q: name, limit: 10 },
});
let tvshow: RTTvSearchResult | undefined = response.data.tvSeries[0];
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
if (year) {
tvshow = response.data.tvSeries.find(
(series) => series.startYear === year
);
tvshow = data.tvSeries.find((series) => series.startYear === year);
}
if (!tvshow) {

View File

@@ -1,9 +1,19 @@
import Axios, { AxiosInstance } from 'axios';
import cacheManager from '../lib/cache';
import { SonarrSettings } from '../lib/settings';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface SonarrSeason {
seasonNumber: number;
monitored: boolean;
statistics?: {
previousAiring?: string;
episodeFileCount: number;
episodeCount: number;
totalEpisodeCount: number;
sizeOnDisk: number;
percentOfEpisodes: number;
};
}
export interface SonarrSeries {
@@ -55,6 +65,33 @@ export interface SonarrSeries {
};
}
interface QueueItem {
seriesId: number;
episodeId: number;
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
downloadId: string;
protocol: string;
downloadClient: string;
indexer: string;
id: number;
}
interface QueueResponse {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: QueueItem[];
}
interface SonarrProfile {
id: number;
name: string;
@@ -83,15 +120,55 @@ interface AddSeriesOptions {
searchNow?: boolean;
}
class SonarrAPI {
private axios: AxiosInstance;
class SonarrAPI extends ExternalAPI {
static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`;
}
constructor({ url, apiKey }: { url: string; apiKey: string }) {
this.axios = Axios.create({
baseURL: url,
params: {
super(
url,
{
apikey: apiKey,
},
});
{
nodeCache: cacheManager.getCache('sonarr').data,
}
);
}
public async getSeries(): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series');
return response.data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`);
}
}
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
params: {
term: title,
},
});
if (!response.data[0]) {
throw new Error('No series found');
}
return response.data;
} catch (e) {
logger.error('Error retrieving series by series title', {
label: 'Sonarr API',
message: e.message,
});
throw new Error('No series found');
}
}
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
@@ -116,7 +193,7 @@ class SonarrAPI {
}
}
public async addSeries(options: AddSeriesOptions): Promise<boolean> {
public async addSeries(options: AddSeriesOptions): Promise<SonarrSeries> {
try {
const series = await this.getSeriesByTvdbId(options.tvdbid);
@@ -138,19 +215,19 @@ class SonarrAPI {
logger.info('Sonarr accepted request. Updated existing series', {
label: 'Sonarr',
});
logger.debug('Sonarr add details', {
logger.debug('Sonarr update details', {
label: 'Sonarr',
movie: newSeriesResponse.data,
});
} else {
logger.error('Failed to add movie to Sonarr', {
logger.error('Failed to update series in Sonarr', {
label: 'Sonarr',
options,
});
return false;
throw new Error('Failed to update series in Sonarr');
}
return true;
return newSeriesResponse.data;
}
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
@@ -189,28 +266,32 @@ class SonarrAPI {
label: 'Sonarr',
options,
});
return false;
throw new Error('Failed to add series to Sonarr');
}
return true;
return createdSeriesResponse.data;
} catch (e) {
logger.error('Something went wrong adding a series to Sonarr', {
logger.error('Something went wrong while adding a series to Sonarr.', {
label: 'Sonarr API',
errorMessage: e.message,
error: e,
response: e?.response?.data,
});
return false;
throw new Error('Failed to add series');
}
}
public async getProfiles(): Promise<SonarrProfile[]> {
try {
const response = await this.axios.get<SonarrProfile[]>('/profile');
const data = await this.getRolling<SonarrProfile[]>(
'/profile',
undefined,
3600
);
return response.data;
return data;
} catch (e) {
logger.error('Something went wrong retrieving Sonarr profiles', {
logger.error('Something went wrong while retrieving Sonarr profiles.', {
label: 'Sonarr API',
message: e.message,
});
@@ -220,14 +301,22 @@ class SonarrAPI {
public async getRootFolders(): Promise<SonarrRootFolder[]> {
try {
const response = await this.axios.get<SonarrRootFolder[]>('/rootfolder');
const data = await this.getRolling<SonarrRootFolder[]>(
'/rootfolder',
undefined,
3600
);
return response.data;
return data;
} catch (e) {
logger.error('Something went wrong retrieving Sonarr root folders', {
label: 'Sonarr API',
message: e.message,
});
logger.error(
'Something went wrong while retrieving Sonarr root folders.',
{
label: 'Sonarr API',
message: e.message,
}
);
throw new Error('Failed to get root folders');
}
}
@@ -256,6 +345,16 @@ class SonarrAPI {
return newSeasons;
}
public getQueue = async (): Promise<QueueItem[]> => {
try {
const response = await this.axios.get<QueueResponse>(`/queue`);
return response.data.records;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
}
};
}
export default SonarrAPI;

View File

@@ -1,934 +0,0 @@
import axios, { AxiosInstance } from 'axios';
export const ANIME_KEYWORD_ID = 210024;
interface SearchOptions {
query: string;
page?: number;
includeAdult?: boolean;
language?: string;
}
interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
language?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc';
}
interface DiscoverTvOptions {
page?: number;
language?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
}
interface TmdbMediaResult {
id: number;
media_type: string;
popularity: number;
poster_path?: string;
backdrop_path?: string;
vote_count: number;
vote_average: number;
genre_ids: number[];
overview: string;
original_language: string;
}
export interface TmdbMovieResult extends TmdbMediaResult {
media_type: 'movie';
title: string;
original_title: string;
release_date: string;
adult: boolean;
video: boolean;
}
export interface TmdbTvResult extends TmdbMediaResult {
media_type: 'tv';
name: string;
original_name: string;
origin_country: string[];
first_air_date: string;
}
export interface TmdbPersonResult {
id: number;
name: string;
popularity: number;
profile_path?: string;
adult: boolean;
media_type: 'person';
known_for: (TmdbMovieResult | TmdbTvResult)[];
}
interface TmdbPaginatedResponse {
page: number;
total_results: number;
total_pages: number;
}
interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
}
interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
results: TmdbMovieResult[];
}
interface TmdbSearchTvResponse extends TmdbPaginatedResponse {
results: TmdbTvResult[];
}
interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse {
dates: {
maximum: string;
minimum: string;
};
results: TmdbMovieResult[];
}
interface TmdbExternalIdResponse {
movie_results: TmdbMovieResult[];
tv_results: TmdbTvResult[];
}
export interface TmdbCreditCast {
cast_id: number;
character: string;
credit_id: string;
gender?: number;
id: number;
name: string;
order: number;
profile_path?: string;
}
export interface TmdbCreditCrew {
credit_id: string;
gender?: number;
id: number;
name: string;
profile_path?: string;
job: string;
department: string;
}
export interface TmdbExternalIds {
imdb_id?: string;
freebase_mid?: string;
freebase_id?: string;
tvdb_id?: number;
tvrage_id?: string;
facebook_id?: string;
instagram_id?: string;
twitter_id?: string;
}
export interface TmdbMovieDetails {
id: number;
imdb_id?: string;
adult: boolean;
backdrop_path?: string;
poster_path?: string;
budget: number;
genres: {
id: number;
name: string;
}[];
homepage?: string;
original_language: string;
original_title: string;
overview?: string;
popularity: number;
production_companies: {
id: number;
name: string;
logo_path?: string;
origin_country: string;
}[];
production_countries: {
iso_3166_1: string;
name: string;
}[];
release_date: string;
revenue: number;
runtime?: number;
spoken_languages: {
iso_639_1: string;
name: string;
}[];
status: string;
tagline?: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
credits: {
cast: TmdbCreditCast[];
crew: TmdbCreditCrew[];
};
belongs_to_collection?: {
id: number;
name: string;
poster_path?: string;
backdrop_path?: string;
};
external_ids: TmdbExternalIds;
videos: TmdbVideoResult;
}
export interface TmdbVideo {
id: string;
key: string;
name: string;
site: 'YouTube';
size: number;
type:
| 'Clip'
| 'Teaser'
| 'Trailer'
| 'Featurette'
| 'Opening Credits'
| 'Behind the Scenes'
| 'Bloopers';
}
export interface TmdbTvEpisodeResult {
id: number;
air_date: string;
episode_number: number;
name: string;
overview: string;
production_code: string;
season_number: number;
show_id: number;
still_path: string;
vote_average: number;
vote_cuont: number;
}
export interface TmdbTvSeasonResult {
id: number;
air_date: string;
episode_count: number;
name: string;
overview: string;
poster_path?: string;
season_number: number;
}
export interface TmdbTvDetails {
id: number;
backdrop_path?: string;
created_by: {
id: number;
credit_id: string;
name: string;
gender: number;
profile_path?: string;
}[];
episode_run_time: number[];
first_air_date: string;
genres: {
id: number;
name: string;
}[];
homepage: string;
in_production: boolean;
languages: string[];
last_air_date: string;
last_episode_to_air?: TmdbTvEpisodeResult;
name: string;
next_episode_to_air?: TmdbTvEpisodeResult;
networks: {
id: number;
name: string;
logo_path: string;
origin_country: string;
}[];
number_of_episodes: number;
number_of_seasons: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path?: string;
production_companies: {
id: number;
logo_path?: string;
name: string;
origin_country: string;
}[];
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
seasons: TmdbTvSeasonResult[];
status: string;
type: string;
vote_average: number;
vote_count: number;
credits: {
cast: TmdbCreditCast[];
crew: TmdbCreditCrew[];
};
external_ids: TmdbExternalIds;
keywords: {
results: TmdbKeyword[];
};
videos: TmdbVideoResult;
}
export interface TmdbVideoResult {
results: TmdbVideo[];
}
export interface TmdbKeyword {
id: number;
name: string;
}
export interface TmdbPersonDetail {
id: number;
name: string;
deathday: string;
known_for_department: string;
also_known_as?: string[];
gender: number;
biography: string;
popularity: string;
place_of_birth?: string;
profile_path?: string;
adult: boolean;
imdb_id?: string;
homepage?: string;
}
export interface TmdbPersonCredit {
id: number;
original_language: string;
episode_count: number;
overview: string;
origin_country: string[];
original_name: string;
vote_count: number;
name: string;
media_type?: string;
popularity: number;
credit_id: string;
backdrop_path?: string;
first_air_date: string;
vote_average: number;
genre_ids?: number[];
poster_path?: string;
original_title: string;
video?: boolean;
title: string;
adult: boolean;
release_date: string;
}
export interface TmdbPersonCreditCast extends TmdbPersonCredit {
character: string;
}
export interface TmdbPersonCreditCrew extends TmdbPersonCredit {
department: string;
job: string;
}
export interface TmdbPersonCombinedCredits {
id: number;
cast: TmdbPersonCreditCast[];
crew: TmdbPersonCreditCrew[];
}
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
episodes: TmdbTvEpisodeResult[];
external_ids: TmdbExternalIds;
}
export interface TmdbCollection {
id: number;
name: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
parts: TmdbMovieResult[];
}
class TheMovieDb {
private apiKey = 'db55323b8d3e4154498498a75642b381';
private axios: AxiosInstance;
constructor() {
this.axios = axios.create({
baseURL: 'https://api.themoviedb.org/3',
params: {
api_key: this.apiKey,
},
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
public searchMulti = async ({
query,
page = 1,
includeAdult = false,
language = 'en-US',
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try {
const response = await this.axios.get('/search/multi', {
params: { query, page, include_adult: includeAdult, language },
});
return response.data;
} catch (e) {
return {
page: 1,
results: [],
total_pages: 1,
total_results: 0,
};
}
};
public getPerson = async ({
personId,
language = 'en-US',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonDetail> => {
try {
const response = await this.axios.get<TmdbPersonDetail>(
`/person/${personId}`,
{
params: { language },
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
}
};
public getPersonCombinedCredits = async ({
personId,
language = 'en-US',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonCombinedCredits> => {
try {
const response = await this.axios.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`,
{
params: { language },
}
);
return response.data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch person combined credits: ${e.message}`
);
}
};
public getMovie = async ({
movieId,
language = 'en-US',
}: {
movieId: number;
language?: string;
}): Promise<TmdbMovieDetails> => {
try {
const response = await this.axios.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: {
language,
append_to_response: 'credits,external_ids,videos',
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
}
};
public getTvShow = async ({
tvId,
language = 'en-US',
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> => {
try {
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
params: {
language,
append_to_response: 'credits,external_ids,keywords,videos',
},
});
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
}
};
public getTvSeason = async ({
tvId,
seasonNumber,
language,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> => {
try {
const response = await this.axios.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`,
{
params: {
language,
append_to_response: 'external_ids',
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
}
};
public async getMovieRecommendations({
movieId,
page = 1,
language = 'en-US',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMovieSimilar({
movieId,
page = 1,
language = 'en-US',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMoviesByKeyword({
keywordId,
page = 1,
language = 'en-US',
}: {
keywordId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
}
}
public async getTvRecommendations({
tvId,
page = 1,
language = 'en-US',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch tv recommendations: ${e.message}`
);
}
}
public async getTvSimilar({
tvId,
page = 1,
language = 'en-US',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
`/tv/${tvId}/similar`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`);
}
}
public getDiscoverMovies = async ({
sortBy = 'popularity.desc',
page = 1,
includeAdult = false,
language = 'en-US',
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
'/discover/movie',
{
params: {
sort_by: sortBy,
page,
include_adult: includeAdult,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
};
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
language = 'en-US',
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
'/discover/tv',
{
params: {
sort_by: sortBy,
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`);
}
};
public getUpcomingMovies = async ({
page = 1,
language = 'en-US',
}: {
page: number;
language: string;
}): Promise<TmdbUpcomingMoviesResponse> => {
try {
const response = await this.axios.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming',
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
}
};
public getAllTrending = async ({
page = 1,
timeWindow = 'day',
language = 'en-US',
}: {
page?: number;
timeWindow?: 'day' | 'week';
language?: string;
} = {}): Promise<TmdbSearchMultiResponse> => {
try {
const response = await this.axios.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public getMovieTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchMovieResponse> => {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`,
{
params: {
page,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public getTvTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchTvResponse> => {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`,
{
params: {
page,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public async getByExternalId({
externalId,
type,
language = 'en-US',
}:
| {
externalId: string;
type: 'imdb';
language?: string;
}
| {
externalId: number;
type: 'tvdb';
language?: string;
}): Promise<TmdbExternalIdResponse> {
try {
const response = await this.axios.get<TmdbExternalIdResponse>(
`/find/${externalId}`,
{
params: {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
}
}
public async getMovieByImdbId({
imdbId,
language = 'en-US',
}: {
imdbId: string;
language?: string;
}): Promise<TmdbMovieDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: imdbId,
type: 'imdb',
});
if (extResponse.movie_results[0]) {
const movie = await this.getMovie({
movieId: extResponse.movie_results[0].id,
language,
});
return movie;
}
throw new Error(
'[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}`
);
}
}
public async getShowByTvdbId({
tvdbId,
language = 'en-US',
}: {
tvdbId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
const tvshow = await this.getTvShow({
tvId: extResponse.tv_results[0].id,
language,
});
return tvshow;
}
throw new Error(
`[TMDB] Failed to find a tv show with the provided TVDB id: ${tvdbId}`
);
} catch (e) {
throw new Error(
`[TMDB] Failed to get tv show by external tvdb ID: ${e.message}`
);
}
}
public async getCollection({
collectionId,
language = 'en-US',
}: {
collectionId: number;
language?: string;
}): Promise<TmdbCollection> {
try {
const response = await this.axios.get<TmdbCollection>(
`/collection/${collectionId}`,
{
params: {
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
}
}
}
export default TheMovieDb;

View File

@@ -0,0 +1 @@
export const ANIME_KEYWORD_ID = 210024;

View File

@@ -0,0 +1,599 @@
import cacheManager from '../../lib/cache';
import ExternalAPI from '../externalapi';
import {
TmdbCollection,
TmdbExternalIdResponse,
TmdbMovieDetails,
TmdbPersonCombinedCredits,
TmdbPersonDetail,
TmdbSearchMovieResponse,
TmdbSearchMultiResponse,
TmdbSearchTvResponse,
TmdbSeasonWithEpisodes,
TmdbTvDetails,
TmdbUpcomingMoviesResponse,
} from './interfaces';
interface SearchOptions {
query: string;
page?: number;
includeAdult?: boolean;
language?: string;
}
interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
language?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc';
}
interface DiscoverTvOptions {
page?: number;
language?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
}
class TheMovieDb extends ExternalAPI {
constructor() {
super(
'https://api.themoviedb.org/3',
{
api_key: 'db55323b8d3e4154498498a75642b381',
},
{
nodeCache: cacheManager.getCache('tmdb').data,
}
);
}
public searchMulti = async ({
query,
page = 1,
includeAdult = false,
language = 'en',
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try {
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
params: { query, page, include_adult: includeAdult, language },
});
return data;
} catch (e) {
return {
page: 1,
results: [],
total_pages: 1,
total_results: 0,
};
}
};
public getPerson = async ({
personId,
language = 'en',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonDetail> => {
try {
const data = await this.get<TmdbPersonDetail>(`/person/${personId}`, {
params: { language },
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
}
};
public getPersonCombinedCredits = async ({
personId,
language = 'en',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonCombinedCredits> => {
try {
const data = await this.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`,
{
params: { language },
}
);
return data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch person combined credits: ${e.message}`
);
}
};
public getMovie = async ({
movieId,
language = 'en',
}: {
movieId: number;
language?: string;
}): Promise<TmdbMovieDetails> => {
try {
const data = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: {
language,
append_to_response: 'credits,external_ids,videos',
},
},
43200
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
}
};
public getTvShow = async ({
tvId,
language = 'en',
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> => {
try {
const data = await this.get<TmdbTvDetails>(
`/tv/${tvId}`,
{
params: {
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos',
},
},
43200
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
}
};
public getTvSeason = async ({
tvId,
seasonNumber,
language,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> => {
try {
const data = await this.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`,
{
params: {
language,
append_to_response: 'external_ids',
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
}
};
public async getMovieRecommendations({
movieId,
page = 1,
language = 'en',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMovieSimilar({
movieId,
page = 1,
language = 'en',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMoviesByKeyword({
keywordId,
page = 1,
language = 'en',
}: {
keywordId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
}
}
public async getTvRecommendations({
tvId,
page = 1,
language = 'en',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const data = await this.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch tv recommendations: ${e.message}`
);
}
}
public async getTvSimilar({
tvId,
page = 1,
language = 'en',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
params: {
page,
language,
},
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`);
}
}
public getDiscoverMovies = async ({
sortBy = 'popularity.desc',
page = 1,
includeAdult = false,
language = 'en',
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: {
sort_by: sortBy,
page,
include_adult: includeAdult,
language,
},
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
};
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
language = 'en',
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: {
sort_by: sortBy,
page,
language,
},
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`);
}
};
public getUpcomingMovies = async ({
page = 1,
language = 'en',
}: {
page: number;
language: string;
}): Promise<TmdbUpcomingMoviesResponse> => {
try {
const data = await this.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming',
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
}
};
public getAllTrending = async ({
page = 1,
timeWindow = 'day',
language = 'en',
}: {
page?: number;
timeWindow?: 'day' | 'week';
language?: string;
} = {}): Promise<TmdbSearchMultiResponse> => {
try {
const data = await this.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public getMovieTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`,
{
params: {
page,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public getTvTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`,
{
params: {
page,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public async getByExternalId({
externalId,
type,
language = 'en',
}:
| {
externalId: string;
type: 'imdb';
language?: string;
}
| {
externalId: number;
type: 'tvdb';
language?: string;
}): Promise<TmdbExternalIdResponse> {
try {
const data = await this.get<TmdbExternalIdResponse>(
`/find/${externalId}`,
{
params: {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
}
}
public async getMovieByImdbId({
imdbId,
language = 'en',
}: {
imdbId: string;
language?: string;
}): Promise<TmdbMovieDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: imdbId,
type: 'imdb',
});
if (extResponse.movie_results[0]) {
const movie = await this.getMovie({
movieId: extResponse.movie_results[0].id,
language,
});
return movie;
}
throw new Error(
'[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}`
);
}
}
public async getShowByTvdbId({
tvdbId,
language = 'en',
}: {
tvdbId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
const tvshow = await this.getTvShow({
tvId: extResponse.tv_results[0].id,
language,
});
return tvshow;
}
throw new Error(
`[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}`
);
}
}
public async getCollection({
collectionId,
language = 'en',
}: {
collectionId: number;
language?: string;
}): Promise<TmdbCollection> {
try {
const data = await this.get<TmdbCollection>(
`/collection/${collectionId}`,
{
params: {
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
}
}
}
export default TheMovieDb;

View File

@@ -0,0 +1,346 @@
interface TmdbMediaResult {
id: number;
media_type: string;
popularity: number;
poster_path?: string;
backdrop_path?: string;
vote_count: number;
vote_average: number;
genre_ids: number[];
overview: string;
original_language: string;
}
export interface TmdbMovieResult extends TmdbMediaResult {
media_type: 'movie';
title: string;
original_title: string;
release_date: string;
adult: boolean;
video: boolean;
}
export interface TmdbTvResult extends TmdbMediaResult {
media_type: 'tv';
name: string;
original_name: string;
origin_country: string[];
first_air_date: string;
}
export interface TmdbPersonResult {
id: number;
name: string;
popularity: number;
profile_path?: string;
adult: boolean;
media_type: 'person';
known_for: (TmdbMovieResult | TmdbTvResult)[];
}
interface TmdbPaginatedResponse {
page: number;
total_results: number;
total_pages: number;
}
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
}
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
results: TmdbMovieResult[];
}
export interface TmdbSearchTvResponse extends TmdbPaginatedResponse {
results: TmdbTvResult[];
}
export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse {
dates: {
maximum: string;
minimum: string;
};
results: TmdbMovieResult[];
}
export interface TmdbExternalIdResponse {
movie_results: TmdbMovieResult[];
tv_results: TmdbTvResult[];
}
export interface TmdbCreditCast {
cast_id: number;
character: string;
credit_id: string;
gender?: number;
id: number;
name: string;
order: number;
profile_path?: string;
}
export interface TmdbAggregateCreditCast extends TmdbCreditCast {
roles: {
credit_id: string;
character: string;
episode_count: number;
}[];
}
export interface TmdbCreditCrew {
credit_id: string;
gender?: number;
id: number;
name: string;
profile_path?: string;
job: string;
department: string;
}
export interface TmdbExternalIds {
imdb_id?: string;
freebase_mid?: string;
freebase_id?: string;
tvdb_id?: number;
tvrage_id?: string;
facebook_id?: string;
instagram_id?: string;
twitter_id?: string;
}
export interface TmdbMovieDetails {
id: number;
imdb_id?: string;
adult: boolean;
backdrop_path?: string;
poster_path?: string;
budget: number;
genres: {
id: number;
name: string;
}[];
homepage?: string;
original_language: string;
original_title: string;
overview?: string;
popularity: number;
production_companies: {
id: number;
name: string;
logo_path?: string;
origin_country: string;
}[];
production_countries: {
iso_3166_1: string;
name: string;
}[];
release_date: string;
revenue: number;
runtime?: number;
spoken_languages: {
iso_639_1: string;
name: string;
}[];
status: string;
tagline?: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
credits: {
cast: TmdbCreditCast[];
crew: TmdbCreditCrew[];
};
belongs_to_collection?: {
id: number;
name: string;
poster_path?: string;
backdrop_path?: string;
};
external_ids: TmdbExternalIds;
videos: TmdbVideoResult;
}
export interface TmdbVideo {
id: string;
key: string;
name: string;
site: 'YouTube';
size: number;
type:
| 'Clip'
| 'Teaser'
| 'Trailer'
| 'Featurette'
| 'Opening Credits'
| 'Behind the Scenes'
| 'Bloopers';
}
export interface TmdbTvEpisodeResult {
id: number;
air_date: string;
episode_number: number;
name: string;
overview: string;
production_code: string;
season_number: number;
show_id: number;
still_path: string;
vote_average: number;
vote_cuont: number;
}
export interface TmdbTvSeasonResult {
id: number;
air_date: string;
episode_count: number;
name: string;
overview: string;
poster_path?: string;
season_number: number;
}
export interface TmdbTvDetails {
id: number;
backdrop_path?: string;
created_by: {
id: number;
credit_id: string;
name: string;
gender: number;
profile_path?: string;
}[];
episode_run_time: number[];
first_air_date: string;
genres: {
id: number;
name: string;
}[];
homepage: string;
in_production: boolean;
languages: string[];
last_air_date: string;
last_episode_to_air?: TmdbTvEpisodeResult;
name: string;
next_episode_to_air?: TmdbTvEpisodeResult;
networks: {
id: number;
name: string;
logo_path: string;
origin_country: string;
}[];
number_of_episodes: number;
number_of_seasons: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path?: string;
production_companies: {
id: number;
logo_path?: string;
name: string;
origin_country: string;
}[];
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
seasons: TmdbTvSeasonResult[];
status: string;
type: string;
vote_average: number;
vote_count: number;
aggregate_credits: {
cast: TmdbAggregateCreditCast[];
};
credits: {
crew: TmdbCreditCrew[];
};
external_ids: TmdbExternalIds;
keywords: {
results: TmdbKeyword[];
};
videos: TmdbVideoResult;
}
export interface TmdbVideoResult {
results: TmdbVideo[];
}
export interface TmdbKeyword {
id: number;
name: string;
}
export interface TmdbPersonDetail {
id: number;
name: string;
deathday: string;
known_for_department: string;
also_known_as?: string[];
gender: number;
biography: string;
popularity: string;
place_of_birth?: string;
profile_path?: string;
adult: boolean;
imdb_id?: string;
homepage?: string;
}
export interface TmdbPersonCredit {
id: number;
original_language: string;
episode_count: number;
overview: string;
origin_country: string[];
original_name: string;
vote_count: number;
name: string;
media_type?: string;
popularity: number;
credit_id: string;
backdrop_path?: string;
first_air_date: string;
vote_average: number;
genre_ids?: number[];
poster_path?: string;
original_title: string;
video?: boolean;
title: string;
adult: boolean;
release_date: string;
}
export interface TmdbPersonCreditCast extends TmdbPersonCredit {
character: string;
}
export interface TmdbPersonCreditCrew extends TmdbPersonCredit {
department: string;
job: string;
}
export interface TmdbPersonCombinedCredits {
id: number;
cast: TmdbPersonCreditCast[];
crew: TmdbPersonCreditCrew[];
}
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
episodes: TmdbTvEpisodeResult[];
external_ids: TmdbExternalIds;
}
export interface TmdbCollection {
id: number;
name: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
parts: TmdbMovieResult[];
}

View File

@@ -8,11 +8,16 @@ import {
UpdateDateColumn,
getRepository,
In,
AfterLoad,
} from 'typeorm';
import { MediaRequest } from './MediaRequest';
import { MediaStatus, MediaType } from '../constants/media';
import logger from '../logger';
import Season from './Season';
import { getSettings } from '../lib/settings';
import RadarrAPI from '../api/radarr';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import SonarrAPI from '../api/sonarr';
@Entity()
class Media {
@@ -104,9 +109,170 @@ class Media {
@Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date;
@Column({ nullable: true })
public serviceId?: number;
@Column({ nullable: true })
public serviceId4k?: number;
@Column({ nullable: true })
public externalServiceId?: number;
@Column({ nullable: true })
public externalServiceId4k?: number;
@Column({ nullable: true })
public externalServiceSlug?: string;
@Column({ nullable: true })
public externalServiceSlug4k?: string;
@Column({ nullable: true })
public ratingKey?: string;
@Column({ nullable: true })
public ratingKey4k?: string;
public serviceUrl?: string;
public serviceUrl4k?: string;
public downloadStatus?: DownloadingItem[] = [];
public downloadStatus4k?: DownloadingItem[] = [];
public plexUrl?: string;
public plexUrl4k?: string;
constructor(init?: Partial<Media>) {
Object.assign(this, init);
}
@AfterLoad()
public setPlexUrls(): void {
const machineId = getSettings().plex.machineId;
if (this.ratingKey) {
this.plexUrl = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey}`;
}
if (this.ratingKey4k) {
this.plexUrl4k = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}`;
}
}
@AfterLoad()
public setServiceUrl(): void {
if (this.mediaType === MediaType.MOVIE) {
if (this.serviceId !== null && this.externalServiceSlug !== null) {
const settings = getSettings();
const server = settings.radarr.find(
(radarr) => radarr.id === this.serviceId
);
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
: RadarrAPI.buildRadarrUrl(
server,
`/movie/${this.externalServiceSlug}`
);
}
}
if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) {
const settings = getSettings();
const server = settings.radarr.find(
(radarr) => radarr.id === this.serviceId4k
);
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug4k}`
: RadarrAPI.buildRadarrUrl(
server,
`/movie/${this.externalServiceSlug4k}`
);
}
}
}
if (this.mediaType === MediaType.TV) {
if (this.serviceId !== null && this.externalServiceSlug !== null) {
const settings = getSettings();
const server = settings.sonarr.find(
(sonarr) => sonarr.id === this.serviceId
);
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug}`
: SonarrAPI.buildSonarrUrl(
server,
`/series/${this.externalServiceSlug}`
);
}
}
if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) {
const settings = getSettings();
const server = settings.sonarr.find(
(sonarr) => sonarr.id === this.serviceId4k
);
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug4k}`
: SonarrAPI.buildSonarrUrl(
server,
`/series/${this.externalServiceSlug4k}`
);
}
}
}
}
@AfterLoad()
public getDownloadingItem(): void {
if (this.mediaType === MediaType.MOVIE) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
) {
this.downloadStatus = downloadTracker.getMovieProgress(
this.serviceId,
this.externalServiceId
);
}
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
) {
this.downloadStatus4k = downloadTracker.getMovieProgress(
this.serviceId4k,
this.externalServiceId4k
);
}
}
if (this.mediaType === MediaType.TV) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
) {
this.downloadStatus = downloadTracker.getSeriesProgress(
this.serviceId,
this.externalServiceId
);
}
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
) {
this.downloadStatus4k = downloadTracker.getSeriesProgress(
this.serviceId4k,
this.externalServiceId4k
);
}
}
}
}
export default Media;

View File

@@ -15,7 +15,8 @@ import { User } from './User';
import Media from './Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
import RadarrAPI from '../api/radarr';
import logger from '../logger';
import SeasonRequest from './SeasonRequest';
@@ -151,6 +152,15 @@ export class MediaRequest {
logger.error('No parent media!', { label: 'Media Request' });
return;
}
if (media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) {
logger.warn(
'Media became available before request was approved. Approval notification will be skipped.',
{ label: 'Media Request' }
);
return;
}
const tmdb = new TheMovieDb();
if (this.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
@@ -192,6 +202,18 @@ export class MediaRequest {
}
}
@AfterInsert()
public async autoapprovalNotification(): Promise<void> {
const settings = getSettings().notifications;
if (
settings.autoapprovalEnabled &&
this.status === MediaRequestStatus.APPROVED
) {
this.notifyApprovedOrDeclined();
}
}
@AfterUpdate()
@AfterInsert()
public async updateParentStatus(): Promise<void> {
@@ -205,7 +227,13 @@ export class MediaRequest {
return;
}
const seasonRequestRepository = getRepository(SeasonRequest);
if (this.status === MediaRequestStatus.APPROVED) {
if (
this.status === MediaRequestStatus.APPROVED &&
// Do not update the status if the item is already partially available or available
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
media[this.is4k ? 'status4k' : 'status'] !==
MediaStatus.PARTIALLY_AVAILABLE
) {
if (this.is4k) {
media.status4k = MediaStatus.PROCESSING;
} else {
@@ -358,6 +386,21 @@ export class MediaRequest {
});
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('Media not present');
return;
}
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
throw new Error('Media already available');
}
// Run this asynchronously so we don't wait for it on the UI side
radarr
.addMovie({
@@ -369,44 +412,55 @@ export class MediaRequest {
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
searchNow: true,
searchNow: !radarrSettings.preventSearch,
})
.then(async (success) => {
if (!success) {
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('Media not present');
return;
}
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added movie request failed to add to Radarr, marking as unknown',
{
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: movie.title,
message: 'Movie failed to add to Radarr',
notifyUser: admin,
media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
.then(async (radarrMovie) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
throw new Error('Media data is missing');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
radarrMovie.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
radarrMovie.titleSlug;
media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id;
await mediaRepository.save(media);
})
.catch(async () => {
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added movie request failed to add to Radarr, marking as unknown',
{
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: movie.title,
message: 'Movie failed to add to Radarr',
notifyUser: admin,
media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) {
throw new Error(
`[MediaRequest] Request failed to send to radarr: ${e.message}`
);
const errorMessage = `Request failed to send to radarr: ${e.message}`;
logger.error('Request failed to send to Radarr', {
label: 'Media Request',
errorMessage,
});
throw new Error(errorMessage);
}
}
}
@@ -464,6 +518,12 @@ export class MediaRequest {
throw new Error('Media data is missing');
}
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
throw new Error('Media already available');
}
const tmdb = new TheMovieDb();
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
@@ -472,8 +532,10 @@ export class MediaRequest {
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!series.external_ids.tvdb_id) {
if (!tvdbId) {
this.handleRemoveParentUpdate();
throw new Error('Series was missing tvdb id');
}
@@ -510,7 +572,7 @@ export class MediaRequest {
if (this.profileId && this.profileId !== qualityProfile) {
qualityProfile = this.profileId;
logger.info(`Request has an override profile id: ${qualityProfile}`, {
logger.info(`Request has an override profile ID: ${qualityProfile}`, {
label: 'Media Request',
});
}
@@ -521,49 +583,68 @@ export class MediaRequest {
profileId: qualityProfile,
rootFolderPath: rootFolder,
title: series.name,
tvdbid: series.external_ids.tvdb_id,
tvdbid: tvdbId,
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
monitored: true,
searchNow: true,
searchNow: !sonarrSettings.preventSearch,
})
.then(async (success) => {
if (!success) {
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added series request failed to add to Sonarr, marking as unknown',
{
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: series.name,
message: 'Series failed to add to Sonarr',
notifyUser: admin,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
media,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
.then(async (sonarrSeries) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: ['requests'],
});
if (!media) {
throw new Error('Media data is missing');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
sonarrSeries.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
sonarrSeries.titleSlug;
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
await mediaRepository.save(media);
})
.catch(async () => {
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added series request failed to add to Sonarr, marking as unknown',
{
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: series.name,
message: 'Series failed to add to Sonarr',
notifyUser: admin,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
media,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) {
throw new Error(
`[MediaRequest] Request failed to send to sonarr: ${e.message}`
);
const errorMessage = `Request failed to send to sonarr: ${e.message}`;
logger.error('Request failed to send to Sonarr', {
label: 'Media Request',
errorMessage,
});
throw new Error(errorMessage);
}
}
}

View File

@@ -6,8 +6,13 @@ import {
UpdateDateColumn,
OneToMany,
RelationCount,
AfterLoad,
} from 'typeorm';
import { Permission, hasPermission } from '../lib/permissions';
import {
Permission,
hasPermission,
PermissionCheckOptions,
} from '../lib/permissions';
import { MediaRequest } from './MediaRequest';
import bcrypt from 'bcrypt';
import path from 'path';
@@ -25,14 +30,19 @@ export class User {
static readonly filteredFields: string[] = ['plexToken', 'password'];
public displayName: string;
@PrimaryGeneratedColumn()
public id: number;
@Column({ unique: true })
public email: string;
@Column()
public username: string;
@Column({ nullable: true })
public plexUsername: string;
@Column({ nullable: true })
public username?: string;
@Column({ nullable: true, select: false })
public password?: string;
@@ -79,8 +89,11 @@ export class User {
return filtered;
}
public hasPermission(permissions: Permission | Permission[]): boolean {
return !!hasPermission(permissions, this.permissions);
public hasPermission(
permissions: Permission | Permission[],
options?: PermissionCheckOptions
): boolean {
return !!hasPermission(permissions, this.permissions, options);
}
public passwordMatch(password: string): Promise<boolean> {
@@ -125,4 +138,9 @@ export class User {
});
}
}
@AfterLoad()
public setDisplayName(): void {
this.displayName = this.username || this.plexUsername;
}
}

View File

@@ -5,6 +5,7 @@ import { createConnection, getRepository } from 'typeorm';
import routes from './routes';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
import session, { Store } from 'express-session';
import { TypeormStore } from 'connect-typeorm/out';
import YAML from 'yamljs';
@@ -22,6 +23,7 @@ import { getAppVersion } from './utils/appVersion';
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';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@@ -59,11 +61,47 @@ app
startJobs();
const server = express();
if (settings.main.trustProxy) {
server.enable('trust proxy');
}
server.use(cookieParser());
server.use(bodyParser.json());
server.use(bodyParser.urlencoded({ extended: true }));
server.use((req, res, next) => {
try {
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
if (descriptor?.writable === true) {
req.ip = getClientIp(req) ?? '';
}
} catch (e) {
logger.error('Failed to attach the ip to the request', {
label: 'Middleware',
message: e.message,
});
} finally {
next();
}
});
if (settings.main.csrfProtection) {
server.use(
csurf({
cookie: {
httpOnly: true,
sameSite: true,
secure: !dev,
},
})
);
server.use((req, res, next) => {
res.cookie('XSRF-TOKEN', req.csrfToken(), {
sameSite: true,
secure: !dev,
});
next();
});
}
// Setup sessions
// Set up sessions
const sessionRespository = getRepository(Session);
server.use(
'/api',

View File

@@ -0,0 +1,45 @@
import { PlexSettings } from '../../lib/settings';
export interface PlexStatus {
settings: PlexSettings;
status: number;
message: string;
}
export interface PlexConnection {
protocol: string;
address: string;
port: number;
uri: string;
local: boolean;
status?: number;
message?: string;
host?: string;
}
export interface PlexDevice {
name: string;
product: string;
productVersion: string;
platform: string;
platformVersion: string;
device: string;
clientIdentifier: string;
createdAt: Date;
lastSeenAt: Date;
provides: string[];
owned: boolean;
accessToken?: string;
publicAddress?: string;
httpsRequired?: boolean;
synced?: boolean;
relay?: boolean;
dnsRebindingProtection?: boolean;
natLoopbackSupported?: boolean;
publicAddressMatches?: boolean;
presence?: boolean;
ownerID?: string;
home?: boolean;
sourceTitle?: string;
connection: PlexConnection[];
}

View File

@@ -7,6 +7,21 @@ export interface SettingsAboutResponse {
export interface PublicSettingsResponse {
initialized: boolean;
applicationTitle: string;
hideAvailable: boolean;
localLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
}
export interface CacheItem {
id: string;
name: string;
stats: {
hits: number;
misses: number;
keys: number;
ksize: number;
vsize: number;
};
}

View File

@@ -1,10 +1,11 @@
import { getRepository } from 'typeorm';
import { User } from '../../entity/User';
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi';
import TheMovieDb, {
import TheMovieDb from '../../api/themoviedb';
import {
TmdbMovieDetails,
TmdbTvDetails,
} from '../../api/themoviedb';
} from '../../api/themoviedb/interfaces';
import Media from '../../entity/Media';
import { MediaStatus, MediaType } from '../../constants/media';
import logger from '../../logger';
@@ -68,6 +69,7 @@ class JobPlexSync {
private async processMovie(plexitem: PlexLibraryItem) {
const mediaRepository = getRepository(Media);
try {
if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
@@ -89,6 +91,15 @@ class JobPlexSync {
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'
@@ -129,6 +140,23 @@ class JobPlexSync {
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(
@@ -151,6 +179,12 @@ class JobPlexSync {
: 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}`);
}
@@ -172,14 +206,14 @@ class JobPlexSync {
}
if (!tmdbMovieId) {
throw new Error('Unable to find TMDB ID');
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}`,
`Failed to process Plex item. ratingKey: ${plexitem.ratingKey}`,
'error',
{
errorMessage: e.message,
@@ -233,6 +267,23 @@ class JobPlexSync {
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(
@@ -263,6 +314,12 @@ class JobPlexSync {
? 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}`);
}
@@ -302,12 +359,14 @@ class JobPlexSync {
let tvShow: TmdbTvDetails | null = null;
try {
const metadata = await this.plexClient.getMetadata(
const ratingKey =
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey,
{ includeChildren: true }
);
plexitem.parentRatingKey ??
plexitem.ratingKey;
const metadata = await this.plexClient.getMetadata(ratingKey, {
includeChildren: true,
});
if (metadata.guid.match(tvdbRegex)) {
const matchedtvdb = metadata.guid.match(tvdbRegex);
@@ -333,7 +392,7 @@ class JobPlexSync {
await this.processHamaSpecials(metadata, Number(tvdbId));
} else {
this.log(
`Hama id ${plexitem.guid} detected, but library agent is not set to Hama`,
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
'warn'
);
}
@@ -343,7 +402,7 @@ class JobPlexSync {
if (!animeList.isLoaded()) {
this.log(
`Hama id ${plexitem.guid} detected, but library agent is not set to Hama`,
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
'warn'
);
} else if (matched?.[1]) {
@@ -399,7 +458,7 @@ class JobPlexSync {
return;
}
// Lets get the available seasons from plex
// Lets get the available seasons from Plex
const seasons = tvShow.seasons;
const media = await this.getExisting(tvShow.id, MediaType.TV);
@@ -427,13 +486,17 @@ class JobPlexSync {
// 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
// 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) =>
episode.Media.some((media) => media.videoResolution !== '4k')
!this.enable4kShow
? true
: episode.Media.some(
(media) => media.videoResolution !== '4k'
)
).length;
// Total episodes that are in 4k
@@ -441,6 +504,23 @@ class JobPlexSync {
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,
@@ -452,9 +532,9 @@ class JobPlexSync {
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
total4k === season.episode_count
this.enable4kShow && total4k === season.episode_count
? MediaStatus.AVAILABLE
: total4k > 0
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status4k;
} else {
@@ -470,9 +550,9 @@ class JobPlexSync {
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
total4k === season.episode_count
this.enable4kShow && total4k === season.episode_count
? MediaStatus.AVAILABLE
: total4k > 0
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
})
@@ -547,20 +627,36 @@ class JobPlexSync {
media.mediaAddedAt = new Date(plexitem.addedAt * 1000);
}
media.status = isAllStandardSeasons
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k = isAll4kSeasons
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
// 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 {
@@ -577,13 +673,15 @@ class JobPlexSync {
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k: isAll4kSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) => season.status4k !== 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}`);
@@ -594,7 +692,7 @@ class JobPlexSync {
}
} catch (e) {
this.log(
`Failed to process plex item. ratingKey: ${
`Failed to process Plex item. ratingKey: ${
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey
@@ -763,7 +861,8 @@ class JobPlexSync {
this.log(
this.isRecentOnly
? 'Recently Added Scan Complete'
: 'Full Scan Complete'
: 'Full Scan Complete',
'info'
);
} catch (e) {
logger.error('Sync interrupted', {

View File

@@ -0,0 +1,248 @@
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,10 +1,17 @@
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';
interface ScheduledJob {
id: string;
job: schedule.Job;
name: string;
type: 'process' | 'command';
running?: () => boolean;
cancelFn?: () => void;
}
export const scheduledJobs: ScheduledJob[] = [];
@@ -12,21 +19,80 @@ export const scheduledJobs: ScheduledJob[] = [];
export const startJobs = (): void => {
// Run recently added plex sync every 5 minutes
scheduledJobs.push({
id: 'plex-recently-added-sync',
name: 'Plex Recently Added Sync',
type: 'process',
job: schedule.scheduleJob('0 */5 * * * *', () => {
logger.info('Starting scheduled job: Plex Recently Added Sync', {
label: 'Jobs',
});
jobPlexRecentSync.run();
}),
running: () => jobPlexRecentSync.status().running,
cancelFn: () => jobPlexRecentSync.cancel(),
});
// Run full plex sync every 24 hours
scheduledJobs.push({
id: 'plex-full-sync',
name: 'Plex Full Library Sync',
type: 'process',
job: schedule.scheduleJob('0 0 3 * * *', () => {
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
jobPlexFullSync.run();
}),
running: () => jobPlexFullSync.status().running,
cancelFn: () => jobPlexFullSync.cancel(),
});
// Run full radarr sync every 24 hours
scheduledJobs.push({
id: 'radarr-sync',
name: 'Radarr Sync',
type: 'process',
job: schedule.scheduleJob('0 0 4 * * *', () => {
logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' });
jobRadarrSync.run();
}),
running: () => jobRadarrSync.status().running,
cancelFn: () => jobRadarrSync.cancel(),
});
// Run full sonarr sync every 24 hours
scheduledJobs.push({
id: 'sonarr-sync',
name: 'Sonarr Sync',
type: 'process',
job: schedule.scheduleJob('0 30 4 * * *', () => {
logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' });
jobSonarrSync.run();
}),
running: () => jobSonarrSync.status().running,
cancelFn: () => jobSonarrSync.cancel(),
});
// Run download sync
scheduledJobs.push({
id: 'download-sync',
name: 'Download Sync',
type: 'command',
job: schedule.scheduleJob('0 * * * * *', () => {
logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' });
downloadTracker.updateDownloads();
}),
});
// Reset download sync
scheduledJobs.push({
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
job: schedule.scheduleJob('0 0 1 * * *', () => {
logger.info('Starting scheduled job: Download Sync Reset', {
label: 'Jobs',
});
downloadTracker.resetDownloadTracker();
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });

View File

@@ -0,0 +1,381 @@
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();

60
server/lib/cache.ts Normal file
View File

@@ -0,0 +1,60 @@
import NodeCache from 'node-cache';
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
class Cache {
public id: AvailableCacheIds;
public data: NodeCache;
public name: string;
constructor(
id: AvailableCacheIds,
name: string,
options: { stdTtl?: number; checkPeriod?: number } = {}
) {
this.id = id;
this.name = name;
this.data = new NodeCache({
stdTTL: options.stdTtl ?? DEFAULT_TTL,
checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD,
});
}
public getStats() {
return this.data.getStats();
}
public flush(): void {
this.data.flushAll();
}
}
class CacheManager {
private availableCaches: Record<AvailableCacheIds, Cache> = {
tmdb: new Cache('tmdb', 'TMDb API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
radarr: new Cache('radarr', 'Radarr API'),
sonarr: new Cache('sonarr', 'Sonarr API'),
rt: new Cache('rt', 'Rotten Tomatoes API', {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
};
public getCache(id: AvailableCacheIds): Cache {
return this.availableCaches[id];
}
public getAllCaches(): Record<string, Cache> {
return this.availableCaches;
}
}
const cacheManager = new CacheManager();
export default cacheManager;

View File

@@ -0,0 +1,195 @@
import { uniqWith } from 'lodash';
import RadarrAPI from '../api/radarr';
import SonarrAPI from '../api/sonarr';
import { MediaType } from '../constants/media';
import logger from '../logger';
import { getSettings } from './settings';
export interface DownloadingItem {
mediaType: MediaType;
externalId: number;
size: number;
sizeLeft: number;
status: string;
timeLeft: string;
estimatedCompletionTime: Date;
title: string;
}
class DownloadTracker {
private radarrServers: Record<number, DownloadingItem[]> = {};
private sonarrServers: Record<number, DownloadingItem[]> = {};
public getMovieProgress(
serverId: number,
externalServiceId: number
): DownloadingItem[] {
if (!this.radarrServers[serverId]) {
return [];
}
return this.radarrServers[serverId].filter(
(item) => item.externalId === externalServiceId
);
}
public getSeriesProgress(
serverId: number,
externalServiceId: number
): DownloadingItem[] {
if (!this.sonarrServers[serverId]) {
return [];
}
return this.sonarrServers[serverId].filter(
(item) => item.externalId === externalServiceId
);
}
public async resetDownloadTracker() {
this.radarrServers = {};
}
public updateDownloads() {
this.updateRadarrDownloads();
this.updateSonarrDownloads();
}
private async updateRadarrDownloads() {
const settings = getSettings();
// Remove duplicate servers
const filteredServers = uniqWith(settings.radarr, (radarrA, radarrB) => {
return (
radarrA.hostname === radarrB.hostname &&
radarrA.port === radarrB.port &&
radarrA.baseUrl === radarrB.baseUrl
);
});
// Load downloads from Radarr servers
Promise.all(
filteredServers.map(async (server) => {
if (server.syncEnabled) {
const radarr = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
this.radarrServers[server.id] = queueItems.map((item) => ({
externalId: item.movieId,
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
mediaType: MediaType.MOVIE,
size: item.size,
sizeLeft: item.sizeleft,
status: item.status,
timeLeft: item.timeleft,
title: item.title,
}));
if (queueItems.length > 0) {
logger.debug(
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
{ label: 'Download Tracker' }
);
}
// Duplicate this data to matching servers
const matchingServers = settings.radarr.filter(
(rs) =>
rs.hostname === server.hostname &&
rs.port === server.port &&
rs.baseUrl === server.baseUrl &&
rs.id !== server.id
);
if (matchingServers.length > 0) {
logger.debug(
`Matching download data to ${matchingServers.length} other Radarr server(s)`,
{ label: 'Download Tracker' }
);
}
matchingServers.forEach((ms) => {
if (ms.syncEnabled) {
this.radarrServers[ms.id] = this.radarrServers[server.id];
}
});
}
})
);
}
private async updateSonarrDownloads() {
const settings = getSettings();
// Remove duplicate servers
const filteredServers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
return (
sonarrA.hostname === sonarrB.hostname &&
sonarrA.port === sonarrB.port &&
sonarrA.baseUrl === sonarrB.baseUrl
);
});
// Load downloads from Radarr servers
Promise.all(
filteredServers.map(async (server) => {
if (server.syncEnabled) {
const radarr = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
this.sonarrServers[server.id] = queueItems.map((item) => ({
externalId: item.seriesId,
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
mediaType: MediaType.TV,
size: item.size,
sizeLeft: item.sizeleft,
status: item.status,
timeLeft: item.timeleft,
title: item.title,
}));
if (queueItems.length > 0) {
logger.debug(
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
{ label: 'Download Tracker' }
);
}
// Duplicate this data to matching servers
const matchingServers = settings.sonarr.filter(
(rs) =>
rs.hostname === server.hostname &&
rs.port === server.port &&
rs.baseUrl === server.baseUrl &&
rs.id !== server.id
);
if (matchingServers.length > 0) {
logger.debug(
`Matching download data to ${matchingServers.length} other Sonarr server(s)`,
{ label: 'Download Tracker' }
);
}
matchingServers.forEach((ms) => {
if (ms.syncEnabled) {
this.sonarrServers[ms.id] = this.sonarrServers[server.id];
}
});
}
})
);
}
}
const downloadTracker = new DownloadTracker();
export default downloadTracker;

View File

@@ -104,7 +104,7 @@ class DiscordAgent
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
value: payload.notifyUser.displayName ?? '',
inline: true,
},
{
@@ -126,7 +126,7 @@ class DiscordAgent
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
value: payload.notifyUser.displayName ?? '',
inline: true,
},
{
@@ -148,7 +148,7 @@ class DiscordAgent
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
value: payload.notifyUser.displayName ?? '',
inline: true,
},
{
@@ -170,7 +170,7 @@ class DiscordAgent
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
value: payload.notifyUser.displayName ?? '',
inline: true,
},
{
@@ -203,7 +203,10 @@ class DiscordAgent
description: payload.message,
color,
timestamp: new Date().toISOString(),
author: { name: 'Overseerr', url: settings.main.applicationUrl },
author: {
name: settings.main.applicationTitle,
url: settings.main.applicationUrl,
},
fields: [
...fields,
// If we have extra data, map it to fields for discord notifications
@@ -236,6 +239,7 @@ class DiscordAgent
): Promise<boolean> {
logger.debug('Sending discord notification', { label: 'Notifications' });
try {
const settings = getSettings();
const webhookUrl = this.getSettings().options.webhookUrl;
if (!webhookUrl) {
@@ -243,7 +247,7 @@ class DiscordAgent
}
await axios.post(webhookUrl, {
username: 'Overseerr',
username: settings.main.applicationTitle,
embeds: [this.buildEmbed(type, payload)],
} as DiscordWebhookPayload);

View File

@@ -36,7 +36,7 @@ class EmailAgent
private async sendMediaRequestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
@@ -60,11 +60,12 @@ class EmailAgent
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'New Request',
},
});
@@ -81,7 +82,7 @@ class EmailAgent
private async sendMediaFailedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
@@ -106,11 +107,12 @@ class EmailAgent
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Failed Request',
},
});
@@ -127,7 +129,7 @@ class EmailAgent
private async sendMediaApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
@@ -144,11 +146,12 @@ class EmailAgent
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Request Approved',
},
});
@@ -164,7 +167,7 @@ class EmailAgent
private async sendMediaDeclinedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
@@ -181,11 +184,12 @@ class EmailAgent
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Request Declined',
},
});
@@ -201,7 +205,7 @@ class EmailAgent
private async sendMediaAvailableEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
@@ -218,11 +222,12 @@ class EmailAgent
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: 'Now Available',
},
});
@@ -238,7 +243,7 @@ class EmailAgent
private async sendTestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const email = new PreparedEmail();
@@ -250,6 +255,7 @@ class EmailAgent
locals: {
body: payload.message,
applicationUrl,
applicationTitle,
},
});
return true;

View File

@@ -48,48 +48,47 @@ class PushoverAgent
const title = payload.subject;
const plot = payload.message;
const user = payload.notifyUser.username;
const username = payload.notifyUser.displayName;
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = 'New Request';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${user}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nPending Approval\n`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = 'Request Approved';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${user}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nProcessing Request\n`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = 'Now available!';
messageTitle = 'Now Available';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${user}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nAvailable\n`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = 'Request Declined';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${user}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nDeclined\n`;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${user}\n`;
message += `<b>Requested By</b>\n${username}\n`;
break;
}
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `<a href="${actionUrl}">Open in Overseerr</a>`;
message += `<a href="${actionUrl}">Open in ${settings.main.applicationTitle}</a>`;
}
return { title: messageTitle, message };

View File

@@ -58,7 +58,7 @@ class SlackAgent
payload: NotificationPayload
): SlackBlockEmbed {
const settings = getSettings();
let header = 'Overseerr';
let header = settings.main.applicationTitle;
let actionUrl: string | undefined;
const fields: EmbedField[] = [];
@@ -69,7 +69,7 @@ class SlackAgent
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
@@ -85,7 +85,7 @@ class SlackAgent
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
@@ -101,7 +101,7 @@ class SlackAgent
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
@@ -117,7 +117,7 @@ class SlackAgent
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
@@ -191,7 +191,7 @@ class SlackAgent
value: 'open_overseerr',
text: {
type: 'plain_text',
text: 'Open Overseerr',
text: `Open ${settings.main.applicationTitle}`,
},
},
],

View File

@@ -51,7 +51,7 @@ class TelegramAgent
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
const user = this.escapeText(payload.notifyUser.username);
const user = this.escapeText(payload.notifyUser.displayName);
/* eslint-disable no-useless-escape */
switch (type) {
@@ -98,7 +98,7 @@ class TelegramAgent
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\[Open in Overseerr\]\(${actionUrl}\)`;
message += `\[Open in ${settings.main.applicationTitle}\]\(${actionUrl}\)`;
}
/* eslint-enable */

View File

@@ -16,7 +16,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
subject: 'subject',
message: 'message',
image: 'image',
notifyuser_username: 'notifyUser.username',
notifyuser_username: 'notifyUser.displayName',
notifyuser_email: 'notifyUser.email',
notifyuser_avatar: 'notifyUser.avatar',
media_tmdbid: 'media.tmdbId',

View File

@@ -1,4 +1,5 @@
import logger from '../../logger';
import { getSettings } from '../settings';
import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification {
@@ -43,11 +44,12 @@ class NotificationManager {
type: Notification,
payload: NotificationPayload
): void {
const settings = getSettings().notifications;
logger.info(`Sending notification for ${Notification[type]}`, {
label: 'Notifications',
});
this.activeAgents.forEach((agent) => {
if (agent.shouldSend(type)) {
if (settings.enabled && agent.shouldSend(type)) {
agent.send(type, payload);
}
});

View File

@@ -13,6 +13,11 @@ export enum Permission {
REQUEST_4K_MOVIE = 2048,
REQUEST_4K_TV = 4096,
REQUEST_ADVANCED = 8192,
REQUEST_VIEW = 16384,
}
export interface PermissionCheckOptions {
type: 'and' | 'or';
}
/**
@@ -22,10 +27,12 @@ export enum Permission {
*
* @param permissions Single permission or array of permissions
* @param value users current permission value
* @param options Extra options to control permission check behavior (mainly for arrays)
*/
export const hasPermission = (
permissions: Permission | Permission[],
value: number
value: number,
options: PermissionCheckOptions = { type: 'and' }
): boolean => {
let total = 0;
@@ -35,8 +42,15 @@ export const hasPermission = (
}
if (Array.isArray(permissions)) {
// Combine all permission values into one
total = permissions.reduce((a, v) => a + v, 0);
if (value & Permission.ADMIN) {
return true;
}
switch (options.type) {
case 'and':
return permissions.every((permission) => !!(value & permission));
case 'or':
return permissions.some((permission) => !!(value & permission));
}
} else {
total = permissions;
}

View File

@@ -32,6 +32,9 @@ interface DVRSettings {
activeDirectory: string;
is4k: boolean;
isDefault: boolean;
externalUrl?: string;
syncEnabled: boolean;
preventSearch: boolean;
}
export interface RadarrSettings extends DVRSettings {
@@ -47,8 +50,13 @@ export interface SonarrSettings extends DVRSettings {
export interface MainSettings {
apiKey: string;
applicationTitle: string;
applicationUrl: string;
csrfProtection: boolean;
defaultPermissions: number;
hideAvailable: boolean;
localLogin: boolean;
trustProxy: boolean;
}
interface PublicSettings {
@@ -56,6 +64,9 @@ interface PublicSettings {
}
interface FullPublicSettings extends PublicSettings {
applicationTitle: string;
hideAvailable: boolean;
localLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
}
@@ -124,6 +135,8 @@ interface NotificationAgents {
}
interface NotificationSettings {
enabled: boolean;
autoapprovalEnabled: boolean;
agents: NotificationAgents;
}
@@ -149,8 +162,13 @@ class Settings {
clientId: uuidv4(),
main: {
apiKey: '',
applicationTitle: 'Overseerr',
applicationUrl: '',
csrfProtection: false,
defaultPermissions: Permission.REQUEST,
hideAvailable: false,
localLogin: true,
trustProxy: false,
},
plex: {
name: '',
@@ -165,6 +183,8 @@ class Settings {
initialized: false,
},
notifications: {
enabled: true,
autoapprovalEnabled: false,
agents: {
email: {
enabled: false,
@@ -275,6 +295,9 @@ class Settings {
get fullPublicSettings(): FullPublicSettings {
return {
...this.data.public,
applicationTitle: this.data.main.applicationTitle,
hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin,
movie4kEnabled: this.data.radarr.some(
(radarr) => radarr.is4k && radarr.isDefault
),

View File

@@ -1,6 +1,6 @@
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import { Permission } from '../lib/permissions';
import { Permission, PermissionCheckOptions } from '../lib/permissions';
import { getSettings } from '../lib/settings';
export const checkUser: Middleware = async (req, _res, next) => {
@@ -34,10 +34,11 @@ export const checkUser: Middleware = async (req, _res, next) => {
};
export const isAuthenticated = (
permissions?: Permission | Permission[]
permissions?: Permission | Permission[],
options?: PermissionCheckOptions
): Middleware => {
const authMiddleware: Middleware = (req, res, next) => {
if (!req.user || !req.user.hasPermission(permissions ?? 0)) {
if (!req.user || !req.user.hasPermission(permissions ?? 0, options)) {
res.status(403).json({
status: 403,
error: 'You do not have permission to access this endpoint',

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDisplayNameToUser1611508672722 implements MigrationInterface {
name = 'AddDisplayNameToUser1611508672722';
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 NOT NULL, "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 "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", "username" FROM "user"`
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
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, 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", "", "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 NOT NULL, "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"`);
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 NOT NULL, "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), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "temporary_user"`
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
}
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { TmdbCollection } from '../api/themoviedb';
import type { TmdbCollection } from '../api/themoviedb/interfaces';
import { MediaType } from '../constants/media';
import Media from '../entity/Media';
import { mapMovieResult, MovieResult } from './Search';

View File

@@ -1,4 +1,4 @@
import { TmdbMovieDetails } from '../api/themoviedb';
import type { TmdbMovieDetails } from '../api/themoviedb/interfaces';
import {
ProductionCompany,
Genre,
@@ -72,6 +72,7 @@ export interface MovieDetails {
};
mediaInfo?: Media;
externalIds: ExternalIds;
plexUrl?: string;
}
export const mapMovieDetails = (

View File

@@ -1,8 +1,8 @@
import {
import type {
TmdbPersonCreditCast,
TmdbPersonCreditCrew,
TmdbPersonDetail,
} from '../api/themoviedb';
} from '../api/themoviedb/interfaces';
import Media from '../entity/Media';
export interface PersonDetail {

View File

@@ -2,7 +2,7 @@ import type {
TmdbMovieResult,
TmdbPersonResult,
TmdbTvResult,
} from '../api/themoviedb';
} from '../api/themoviedb/interfaces';
import { MediaType as MainMediaType } from '../constants/media';
import Media from '../entity/Media';

View File

@@ -3,19 +3,19 @@ import {
ProductionCompany,
Cast,
Crew,
mapCast,
mapAggregateCast,
mapCrew,
ExternalIds,
mapExternalIds,
Keyword,
mapVideos,
} from './common';
import {
import type {
TmdbTvEpisodeResult,
TmdbTvSeasonResult,
TmdbTvDetails,
TmdbSeasonWithEpisodes,
} from '../api/themoviedb';
} from '../api/themoviedb/interfaces';
import type Media from '../entity/Media';
import { Video } from './Movie';
@@ -193,7 +193,7 @@ export const mapTvDetails = (
: undefined,
posterPath: show.poster_path,
credits: {
cast: show.credits.cast.map(mapCast),
cast: show.aggregate_credits.cast.map(mapAggregateCast),
crew: show.credits.crew.map(mapCrew),
},
externalIds: mapExternalIds(show.external_ids),

View File

@@ -1,10 +1,11 @@
import {
import type {
TmdbCreditCast,
TmdbAggregateCreditCast,
TmdbCreditCrew,
TmdbExternalIds,
TmdbVideo,
TmdbVideoResult,
} from '../api/themoviedb';
} from '../api/themoviedb/interfaces';
import { Video } from '../models/Movie';
@@ -68,6 +69,18 @@ export const mapCast = (person: TmdbCreditCast): Cast => ({
profilePath: person.profile_path,
});
export const mapAggregateCast = (person: TmdbAggregateCreditCast): Cast => ({
castId: person.cast_id,
// the first role is the one for which the actor appears the most as
character: person.roles[0].character,
creditId: person.roles[0].credit_id,
id: person.id,
name: person.name,
order: person.order,
gender: person.gender,
profilePath: person.profile_path,
});
export const mapCrew = (person: TmdbCreditCrew): Crew => ({
creditId: person.credit_id,
department: person.department,

View File

@@ -48,13 +48,17 @@ authRoutes.post('/login', async (req, res, next) => {
// Let's check if their plex token is up to date
if (user.plexToken !== body.authToken) {
user.plexToken = body.authToken;
await userRepository.save(user);
}
// Update the users avatar with their plex thumbnail (incase it changed)
user.avatar = account.thumb;
user.email = account.email;
user.username = account.username;
user.plexUsername = account.username;
if (user.username === account.username) {
user.username = '';
}
await userRepository.save(user);
} else {
// Here we check if it's the first user. If it is, we create the user with no check
// and give them admin permissions
@@ -63,7 +67,7 @@ authRoutes.post('/login', async (req, res, next) => {
if (totalUsers === 0) {
user = new User({
email: account.email,
username: account.username,
plexUsername: account.username,
plexId: account.id,
plexToken: account.authToken,
permissions: Permission.ADMIN,
@@ -86,7 +90,7 @@ authRoutes.post('/login', async (req, res, next) => {
if (await mainPlexTv.checkUserAccess(account)) {
user = new User({
email: account.email,
username: account.username,
plexUsername: account.username,
plexId: account.id,
plexToken: account.authToken,
permissions: settings.main.defaultPermissions,
@@ -130,10 +134,13 @@ authRoutes.post('/login', async (req, res, next) => {
});
authRoutes.post('/local', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as { email?: string; password?: string };
if (!body.email || !body.password) {
if (!settings.main.localLogin) {
return res.status(500).json({ error: 'Local user login is disabled' });
} else if (!body.email || !body.password) {
return res
.status(500)
.json({ error: 'You must provide an email and a password' });
@@ -141,7 +148,7 @@ authRoutes.post('/local', async (req, res, next) => {
try {
const user = await userRepository.findOne({
select: ['id', 'password'],
where: { email: body.email, userType: UserType.LOCAL },
where: { email: body.email },
});
const isCorrectCredentials = await user?.passwordMatch(body.password);
@@ -151,6 +158,7 @@ authRoutes.post('/local', async (req, res, next) => {
logger.info('Failed login attempt from user with incorrect credentials', {
label: 'Auth',
account: {
ip: req.ip,
email: body.email,
password: '__REDACTED__',
},
@@ -181,7 +189,7 @@ authRoutes.get('/logout', (req, res, next) => {
if (err) {
return next({
status: 500,
message: 'Something went wrong while attempting to logout',
message: 'Something went wrong while attempting to sign out.',
});
}

View File

@@ -15,6 +15,7 @@ import personRoutes from './person';
import collectionRoutes from './collection';
import { getAppVersion, getCommitTag } from '../utils/appVersion';
import serviceRoutes from './service';
import { appDataStatus, appDataPath } from '../utils/appDataVolume';
const router = Router();
@@ -27,6 +28,13 @@ router.get('/status', (req, res) => {
});
});
router.get('/status/appdata', (_req, res) => {
return res.status(200).json({
appData: appDataStatus(),
appDataPath: appDataPath(),
});
});
router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user);
router.get('/settings/public', (_req, res) => {
const settings = getSettings();

View File

@@ -1,7 +1,7 @@
import { Router } from 'express';
import { getRepository, FindOperator, FindOneOptions } from 'typeorm';
import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm';
import Media from '../entity/Media';
import { MediaStatus } from '../constants/media';
import { MediaStatus, MediaType } from '../constants/media';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
@@ -27,6 +27,12 @@ mediaRoutes.get('/', async (req, res, next) => {
case 'partial':
statusFilter = MediaStatus.PARTIALLY_AVAILABLE;
break;
case 'allavailable':
statusFilter = In([
MediaStatus.AVAILABLE,
MediaStatus.PARTIALLY_AVAILABLE,
]);
break;
case 'processing':
statusFilter = MediaStatus.PROCESSING;
break;
@@ -76,6 +82,63 @@ mediaRoutes.get('/', async (req, res, next) => {
}
});
mediaRoutes.get<
{
id: string;
status: 'available' | 'partial' | 'processing' | 'pending' | 'unknown';
},
Media
>(
'/:id/:status',
isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!media) {
return next({ status: 404, message: 'Media does not exist.' });
}
const is4k = Boolean(req.query.is4k);
switch (req.params.status) {
case 'available':
media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
if (media.mediaType === MediaType.TV) {
// Mark all seasons available
media.seasons.forEach((season) => {
season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
});
}
break;
case 'partial':
if (media.mediaType === MediaType.MOVIE) {
return next({
status: 400,
message: 'Only series can be set to be partially available',
});
}
media.status = MediaStatus.PARTIALLY_AVAILABLE;
break;
case 'processing':
media.status = MediaStatus.PROCESSING;
break;
case 'pending':
media.status = MediaStatus.PENDING;
break;
case 'unknown':
media.status = MediaStatus.UNKNOWN;
}
await mediaRepository.save(media);
return res.status(200).json(media);
}
);
mediaRoutes.delete(
'/:id',
isAuthenticated(Permission.MANAGE_REQUESTS),

View File

@@ -42,24 +42,28 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
);
return res.status(200).json({
cast: combinedCredits.cast.map((result) =>
mapCastCredits(
result,
castMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
cast: combinedCredits.cast
.map((result) =>
mapCastCredits(
result,
castMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
)
)
)
),
crew: combinedCredits.crew.map((result) =>
mapCrewCredits(
result,
crewMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
.filter((item) => !item.adult),
crew: combinedCredits.crew
.map((result) =>
mapCrewCredits(
result,
crewMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
)
)
)
),
.filter((item) => !item.adult),
id: combinedCredits.id,
});
});

View File

@@ -9,6 +9,7 @@ import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import SeasonRequest from '../entity/SeasonRequest';
import logger from '../logger';
import { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
import { User } from '../entity/User';
const requestRoutes = Router();
@@ -56,7 +57,8 @@ requestRoutes.get('/', async (req, res, next) => {
}
const [requests, requestCount] = req.user?.hasPermission(
Permission.MANAGE_REQUESTS
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
)
? await requestRepository.findAndCount({
order: sortFilter,
@@ -94,8 +96,28 @@ requestRoutes.post(
const tmdb = new TheMovieDb();
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
try {
let requestUser = req.user;
if (
req.body.userId &&
!req.user?.hasPermission([
Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS,
])
) {
return next({
status: 403,
message: 'You do not have permission to modify the request user.',
});
} else if (req.body.userId) {
requestUser = await userRepository.findOneOrFail({
where: { id: req.body.userId },
});
}
const tmdbMedia =
req.body.mediaType === 'movie'
? await tmdb.getMovie({ movieId: req.body.mediaId })
@@ -109,29 +131,49 @@ requestRoutes.post(
if (!media) {
media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: tmdbMedia.external_ids.tvdb_id,
tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: req.body.mediaType,
});
await mediaRepository.save(media);
} else {
if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
media.status = MediaStatus.PENDING;
await mediaRepository.save(media);
}
if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
media.status4k = MediaStatus.PENDING;
await mediaRepository.save(media);
}
}
if (req.body.mediaType === 'movie') {
const existing = await requestRepository.findOne({
where: {
media: {
tmdbId: tmdbMedia.id,
},
requestedBy: req.user,
is4k: req.body.is4k,
},
});
if (existing) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
mediaType: req.body.mediaType,
});
return next({
status: 409,
message: 'Request for this media already exists.',
});
}
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.MOVIE,
media,
requestedBy: req.user,
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) ||
@@ -160,7 +202,11 @@ requestRoutes.post(
// (Unless there are no seasons, in which case we abort)
if (media.requests) {
existingSeasons = media.requests
.filter((request) => request.is4k === req.body.is4k)
.filter(
(request) =>
request.is4k === req.body.is4k &&
request.status !== MediaRequestStatus.DECLINED
)
.reduce((seasons, request) => {
const combinedSeasons = request.seasons.map(
(season) => season.seasonNumber
@@ -181,12 +227,14 @@ requestRoutes.post(
});
}
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.TV,
media: {
id: media.id,
} as Media,
requestedBy: req.user,
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) ||
@@ -266,6 +314,7 @@ requestRoutes.put<{ requestId: string }>(
isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
try {
const request = await requestRepository.findOne(
Number(req.params.requestId)
@@ -275,10 +324,30 @@ requestRoutes.put<{ requestId: string }>(
return next({ status: 404, message: 'Request not found' });
}
let requestUser = req.user;
if (
req.body.userId &&
!(
req.user?.hasPermission(Permission.MANAGE_USERS) &&
req.user?.hasPermission(Permission.MANAGE_REQUESTS)
)
) {
return next({
status: 403,
message: 'You do not have permission to modify the request user.',
});
} else if (req.body.userId) {
requestUser = await userRepository.findOneOrFail({
where: { id: req.body.userId },
});
}
if (req.body.mediaType === 'movie') {
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
request.requestedBy = requestUser as User;
requestRepository.save(request);
} else if (req.body.mediaType === 'tv') {
@@ -286,6 +355,7 @@ requestRoutes.put<{ requestId: string }>(
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
request.requestedBy = requestUser as User;
const requestedSeasons = req.body.seasons as number[] | undefined;
@@ -303,7 +373,12 @@ requestRoutes.put<{ requestId: string }>(
// Get all requested seasons that are not part of this request we are editing
const existingSeasons = media.requests
.filter((r) => r.is4k === request.is4k && r.id !== request.id)
.filter(
(r) =>
r.is4k === request.is4k &&
r.id !== request.id &&
r.status !== MediaRequestStatus.DECLINED
)
.reduce((seasons, r) => {
const combinedSeasons = r.seasons.map(
(season) => season.seasonNumber

View File

@@ -6,6 +6,8 @@ import {
ServiceCommonServerWithDetails,
} from '../interfaces/api/serviceInterfaces';
import { getSettings } from '../lib/settings';
import TheMovieDb from '../api/themoviedb';
import logger from '../logger';
const serviceRoutes = Router();
@@ -100,13 +102,13 @@ serviceRoutes.get<{ sonarrId: string }>(
const settings = getSettings();
const sonarrSettings = settings.sonarr.find(
(radarr) => radarr.id === Number(req.params.sonarrId)
(sonarr) => sonarr.id === Number(req.params.sonarrId)
);
if (!sonarrSettings) {
return next({
status: 404,
message: 'Radarr server with provided ID does not exist.',
message: 'Sonarr server with provided ID does not exist.',
});
}
@@ -145,4 +147,52 @@ serviceRoutes.get<{ sonarrId: string }>(
}
);
serviceRoutes.get<{ tmdbId: string }>(
'/sonarr/lookup/:tmdbId',
async (req, res, next) => {
const settings = getSettings();
const tmdb = new TheMovieDb();
const sonarrSettings = settings.sonarr[0];
if (!sonarrSettings) {
logger.error('No sonarr server has been setup', {
label: 'Media Request',
});
return next({
status: 404,
message: 'No sonarr server has been setup',
});
}
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
});
try {
const tv = await tmdb.getTvShow({
tvId: Number(req.params.tmdbId),
language: req.query.language as string,
});
const response = await sonarr.getSeriesByTitle(tv.name);
return res.status(200).json(response);
} catch (e) {
logger.error('Failed to fetch tvdb search results', {
label: 'Media Request',
message: e.message,
});
return next({
status: 500,
message: 'Something went wrong trying to fetch series information',
});
}
}
);
export default serviceRoutes;

View File

@@ -1,18 +1,10 @@
import { Router } from 'express';
import {
getSettings,
RadarrSettings,
SonarrSettings,
Library,
MainSettings,
} from '../../lib/settings';
import { getSettings, Library, MainSettings } from '../../lib/settings';
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 SonarrAPI from '../../api/sonarr';
import RadarrAPI from '../../api/radarr';
import logger from '../../logger';
import { scheduledJobs } from '../../job/schedule';
import { Permission } from '../../lib/permissions';
import { isAuthenticated } from '../../middleware/auth';
@@ -22,10 +14,15 @@ import { MediaRequest } from '../../entity/MediaRequest';
import { getAppVersion } from '../../utils/appVersion';
import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces';
import notificationRoutes from './notifications';
import sonarrRoutes from './sonarr';
import radarrRoutes from './radarr';
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
const filteredMainSettings = (
user: User,
@@ -106,6 +103,69 @@ settingsRoutes.post('/plex', async (req, res, next) => {
return res.status(200).json(settings.plex);
});
settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
const userRepository = getRepository(User);
const regexp = /(http(s?):\/\/)(.*)(:[0-9]*)/;
try {
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
const plexTvClient = admin.plexToken
? new PlexTvAPI(admin.plexToken)
: null;
const devices = (await plexTvClient?.getDevices())?.filter((device) => {
return device.provides.includes('server') && device.owned;
});
const settings = getSettings();
if (devices) {
await Promise.all(
devices.map(async (device) => {
await Promise.all(
device.connection.map(async (connection) => {
connection.host = connection.uri.replace(regexp, '$3');
let msg:
| { status: number; message: string }
| undefined = undefined;
const plexDeviceSettings = {
...settings.plex,
ip: connection.host,
port: connection.port,
useSsl: connection.protocol === 'https' ? true : false,
};
const plexClient = new PlexAPI({
plexToken: admin.plexToken,
plexSettings: plexDeviceSettings,
timeout: 5000,
});
try {
await plexClient.getStatus();
msg = {
status: 200,
message: 'OK',
};
} catch (e) {
msg = {
status: 500,
message: e.message,
};
}
connection.status = msg?.status;
connection.message = msg?.message;
})
);
})
);
}
return res.status(200).json(devices);
} catch (e) {
return next({
status: 500,
message: `Failed to connect to Plex: ${e.message}`,
});
}
});
settingsRoutes.get('/plex/library', async (req, res) => {
const settings = getSettings();
@@ -156,271 +216,90 @@ settingsRoutes.get('/plex/sync', (req, res) => {
} else if (req.query.start) {
jobPlexFullSync.run();
}
return res.status(200).json(jobPlexFullSync.status());
});
settingsRoutes.get('/radarr', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.radarr);
});
settingsRoutes.post('/radarr', (req, res) => {
const settings = getSettings();
const newRadarr = req.body as RadarrSettings;
const lastItem = settings.radarr[settings.radarr.length - 1];
newRadarr.id = lastItem ? lastItem.id + 1 : 0;
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => {
radarrInstance.isDefault = false;
});
}
settings.radarr = [...settings.radarr, newRadarr];
settings.save();
return res.status(201).json(newRadarr);
});
settingsRoutes.post('/radarr/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`,
});
const profiles = await radarr.getProfiles();
const folders = await radarr.getRootFolders();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
});
} catch (e) {
logger.error('Failed to test Radarr', {
label: 'Radarr',
message: e.message,
});
next({ status: 500, message: 'Failed to connect to Radarr' });
}
});
settingsRoutes.put<{ id: string }>('/radarr/:id', (req, res) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => {
radarrInstance.isDefault = false;
});
}
settings.radarr[radarrIndex] = {
...req.body,
id: Number(req.params.id),
} as RadarrSettings;
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
});
settingsRoutes.get<{ id: string }>('/radarr/:id/profiles', async (req, res) => {
const settings = getSettings();
const radarrSettings = settings.radarr.find(
(r) => r.id === Number(req.params.id)
);
if (!radarrSettings) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
});
const profiles = await radarr.getProfiles();
return res.status(200).json(
profiles.map((profile) => ({
id: profile.id,
name: profile.name,
}))
);
});
settingsRoutes.delete<{ id: string }>('/radarr/:id', (req, res) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.radarr.splice(radarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
settingsRoutes.get('/sonarr', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.sonarr);
});
settingsRoutes.post('/sonarr', (req, res) => {
const settings = getSettings();
const newSonarr = req.body as SonarrSettings;
const lastItem = settings.sonarr[settings.sonarr.length - 1];
newSonarr.id = lastItem ? lastItem.id + 1 : 0;
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.sonarr
.filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k)
.forEach((sonarrInstance) => {
sonarrInstance.isDefault = false;
});
}
settings.sonarr = [...settings.sonarr, newSonarr];
settings.save();
return res.status(201).json(newSonarr);
});
settingsRoutes.post('/sonarr/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`,
});
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
});
} catch (e) {
logger.error('Failed to test Sonarr', {
label: 'Sonarr',
message: e.message,
});
next({ status: 500, message: 'Failed to connect to Sonarr' });
}
});
settingsRoutes.put<{ id: string }>('/sonarr/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (sonarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.sonarr
.filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k)
.forEach((sonarrInstance) => {
sonarrInstance.isDefault = false;
});
}
settings.sonarr[sonarrIndex] = {
...req.body,
id: Number(req.params.id),
} as SonarrSettings;
settings.save();
return res.status(200).json(settings.sonarr[sonarrIndex]);
});
settingsRoutes.delete<{ id: string }>('/sonarr/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (sonarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.sonarr.splice(sonarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
settingsRoutes.get('/jobs', (_req, res) => {
return res.status(200).json(
scheduledJobs.map((job) => ({
id: job.id,
name: job.name,
type: job.type,
nextExecutionTime: job.job.nextInvocation(),
running: job.running ? job.running() : false,
}))
);
});
settingsRoutes.get<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
const scheduledJob = scheduledJobs.find((job) => job.id === req.params.jobId);
if (!scheduledJob) {
return next({ status: 404, message: 'Job not found' });
}
scheduledJob.job.invoke();
return res.status(200).json({
id: scheduledJob.id,
name: scheduledJob.name,
type: scheduledJob.type,
nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false,
});
});
settingsRoutes.get<{ jobId: string }>(
'/jobs/:jobId/cancel',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
(job) => job.id === req.params.jobId
);
if (!scheduledJob) {
return next({ status: 404, message: 'Job not found' });
}
if (scheduledJob.cancelFn) {
scheduledJob.cancelFn();
}
return res.status(200).json({
id: scheduledJob.id,
name: scheduledJob.name,
type: scheduledJob.type,
nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false,
});
}
);
settingsRoutes.get('/cache', (req, res) => {
const caches = cacheManager.getAllCaches();
return res.status(200).json(
Object.values(caches).map((cache) => ({
id: cache.id,
name: cache.name,
stats: cache.getStats(),
}))
);
});
settingsRoutes.get<{ cacheId: AvailableCacheIds }>(
'/cache/:cacheId/flush',
(req, res, next) => {
const cache = cacheManager.getCache(req.params.cacheId);
if (cache) {
cache.flush();
return res.status(204).send();
}
next({ status: 404, message: 'Cache does not exist.' });
}
);
settingsRoutes.get(
'/initialize',
isAuthenticated(Permission.ADMIN),

View File

@@ -10,6 +10,29 @@ import WebhookAgent from '../../lib/notifications/agents/webhook';
const notificationRoutes = Router();
notificationRoutes.get('/', (_req, res) => {
const settings = getSettings().notifications;
return res.status(200).json({
enabled: settings.enabled,
autoapprovalEnabled: settings.autoapprovalEnabled,
});
});
notificationRoutes.post('/', (req, res) => {
const settings = getSettings();
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,
});
});
notificationRoutes.get('/discord', (_req, res) => {
const settings = getSettings();

View File

@@ -0,0 +1,149 @@
import { Router } from 'express';
import RadarrAPI from '../../api/radarr';
import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger';
const radarrRoutes = Router();
radarrRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.radarr);
});
radarrRoutes.post('/', (req, res) => {
const settings = getSettings();
const newRadarr = req.body as RadarrSettings;
const lastItem = settings.radarr[settings.radarr.length - 1];
newRadarr.id = lastItem ? lastItem.id + 1 : 0;
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => {
radarrInstance.isDefault = false;
});
}
settings.radarr = [...settings.radarr, newRadarr];
settings.save();
return res.status(201).json(newRadarr);
});
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`,
});
const profiles = await radarr.getProfiles();
const folders = await radarr.getRootFolders();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
});
} catch (e) {
logger.error('Failed to test Radarr', {
label: 'Radarr',
message: e.message,
});
next({ status: 500, message: 'Failed to connect to Radarr' });
}
});
radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => {
radarrInstance.isDefault = false;
});
}
settings.radarr[radarrIndex] = {
...req.body,
id: Number(req.params.id),
} as RadarrSettings;
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
});
radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
const settings = getSettings();
const radarrSettings = settings.radarr.find(
(r) => r.id === Number(req.params.id)
);
if (!radarrSettings) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
});
const profiles = await radarr.getProfiles();
return res.status(200).json(
profiles.map((profile) => ({
id: profile.id,
name: profile.name,
}))
);
});
radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.radarr.splice(radarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
export default radarrRoutes;

View File

@@ -0,0 +1,119 @@
import { Router } from 'express';
import SonarrAPI from '../../api/sonarr';
import { getSettings, SonarrSettings } from '../../lib/settings';
import logger from '../../logger';
const sonarrRoutes = Router();
sonarrRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.sonarr);
});
sonarrRoutes.post('/', (req, res) => {
const settings = getSettings();
const newSonarr = req.body as SonarrSettings;
const lastItem = settings.sonarr[settings.sonarr.length - 1];
newSonarr.id = lastItem ? lastItem.id + 1 : 0;
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.sonarr
.filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k)
.forEach((sonarrInstance) => {
sonarrInstance.isDefault = false;
});
}
settings.sonarr = [...settings.sonarr, newSonarr];
settings.save();
return res.status(201).json(newSonarr);
});
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`,
});
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
});
} catch (e) {
logger.error('Failed to test Sonarr', {
label: 'Sonarr',
message: e.message,
});
next({ status: 500, message: 'Failed to connect to Sonarr' });
}
});
sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (sonarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.sonarr
.filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k)
.forEach((sonarrInstance) => {
sonarrInstance.isDefault = false;
});
}
settings.sonarr[sonarrIndex] = {
...req.body,
id: Number(req.params.id),
} as SonarrSettings;
settings.save();
return res.status(200).json(settings.sonarr[sonarrIndex]);
});
sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (sonarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.sonarr.splice(sonarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
export default sonarrRoutes;

View File

@@ -1,5 +1,5 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import { getRepository, Not } from 'typeorm';
import PlexTvAPI from '../api/plextv';
import { MediaRequest } from '../entity/MediaRequest';
import { User } from '../entity/User';
@@ -21,7 +21,7 @@ router.get('/', async (_req, res) => {
router.post('/', async (req, res, next) => {
try {
const settings = getSettings().notifications.agents.email;
const settings = getSettings();
const body = req.body;
const userRepository = getRepository(User);
@@ -29,7 +29,7 @@ router.post('/', async (req, res, next) => {
const passedExplicitPassword = body.password && body.password.length > 0;
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
if (!passedExplicitPassword && !settings.enabled) {
if (!passedExplicitPassword && !settings.notifications.agents.email) {
throw new Error('Email notifications must be enabled');
}
@@ -38,7 +38,7 @@ router.post('/', async (req, res, next) => {
username: body.username ?? body.email,
email: body.email,
password: body.password,
permissions: Permission.REQUEST,
permissions: settings.main.defaultPermissions,
plexToken: '',
userType: UserType.LOCAL,
});
@@ -70,6 +70,51 @@ router.get<{ id: string }>('/:id', async (req, res, next) => {
}
});
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);
@@ -86,29 +131,18 @@ router.put<{ id: string }>('/:id', async (req, res, next) => {
});
}
// Only let the owner grant admin privileges
if (
hasPermission(Permission.ADMIN, req.body.permissions) &&
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',
});
}
// Only let users with the manage settings permission, grant the same permission
if (
hasPermission(Permission.MANAGE_SETTINGS, req.body.permissions) &&
!hasPermission(Permission.MANAGE_SETTINGS, req.user?.permissions ?? 0)
) {
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,
});
Object.assign(user, req.body);
await userRepository.save(user);
return res.status(200).json(user.filter());
@@ -183,20 +217,32 @@ router.post('/import-from-plex', async (req, res, next) => {
const createdUsers: User[] = [];
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
const user = await userRepository.findOne({
where: { plexId: account.id },
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.username = account.username;
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({
username: account.username,
plexUsername: account.username,
email: account.email,
permissions: settings.main.defaultPermissions,
plexId: parseInt(account.id),

View File

@@ -5,7 +5,7 @@ import {
UpdateEvent,
} from 'typeorm';
import TheMovieDb from '../api/themoviedb';
import { MediaStatus, MediaType } from '../constants/media';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import Media from '../entity/Media';
import { MediaRequest } from '../entity/MediaRequest';
import Season from '../entity/Season';
@@ -21,7 +21,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
if (entity.mediaType === MediaType.MOVIE) {
const requestRepository = getRepository(MediaRequest);
const relatedRequests = await requestRepository.find({
where: { media: entity },
where: { media: entity, is4k: false },
});
if (relatedRequests.length > 0) {
@@ -64,7 +64,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
for (const changedSeasonNumber of changedSeasons) {
const requests = await requestRepository.find({
where: { media: entity },
where: { media: entity, is4k: false },
});
const request = requests.find(
(request) =>
@@ -102,6 +102,24 @@ export class MediaSubscriber implements EntitySubscriberInterface {
}
}
private async updateChildRequestStatus(event: Media, is4k: boolean) {
const requestRepository = getRepository(MediaRequest);
const requests = await requestRepository.find({
where: { media: event.id },
});
for (const request of requests) {
if (
request.is4k === is4k &&
request.status === MediaRequestStatus.PENDING
) {
request.status = MediaRequestStatus.APPROVED;
await requestRepository.save(request);
}
}
}
public beforeUpdate(event: UpdateEvent<Media>): void {
if (!event.entity) {
return;
@@ -121,5 +139,19 @@ export class MediaSubscriber implements EntitySubscriberInterface {
) {
this.notifyAvailableSeries(event.entity, event.databaseEntity);
}
if (
event.entity.status === MediaStatus.AVAILABLE &&
event.databaseEntity.status === MediaStatus.PENDING
) {
this.updateChildRequestStatus(event.entity, false);
}
if (
event.entity.status4k === MediaStatus.AVAILABLE &&
event.databaseEntity.status4k === MediaStatus.PENDING
) {
this.updateChildRequestStatus(event.entity, true);
}
}
}

View File

@@ -54,7 +54,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
color: #a8aaaf;\
text-decoration: none;\
')
| Overseerr
| #{applicationTitle}
tr
td(style='width: 100%' width='100%')
table.sm-w-full(align='center' style='\
@@ -75,8 +75,8 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
tr
td
table(cellpadding='0' cellspacing='0' role='presentation')
img(src=imageUrl alt='')
p
a(href=actionUrl style='color: #3869d4')
img(src=imageUrl alt='')
p(style='\
font-size: 16px;\
line-height: 24px;\
@@ -92,7 +92,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
margin-bottom: 20px;\
color: #51545e;\
')
a(href=actionUrl style='color: #3869d4') Open Media in Overseerr
a(href=actionUrl style='color: #3869d4') Open in #{applicationTitle}
tr
td
table.sm-w-full(align='center' style='\
@@ -111,4 +111,4 @@ tr
text-align: center;\
color: #a8aaaf;\
')
| Overseerr.
| #{applicationTitle}

View File

@@ -1,224 +0,0 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<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;amp;display=swap"
rel="stylesheet"
media="screen"
/>
<!--[if mso]>
<xml
><o:OfficeDocumentSettings
><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings
></xml
>
<style>
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Segoe UI', sans-serif;
mso-line-height-rule: exactly;
}
</style>
<![endif]-->
<style>
@media (max-width: 600px) {
.sm-w-full {
width: 100% !important;
}
}
</style>
</head>
<body
style="
margin: 0;
padding: 0;
width: 100%;
word-break: break-word;
-webkit-font-smoothing: antialiased;
background-color: #f2f4f6;
"
>
<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="https://example.com"
style="
text-shadow: 0 1px 0 #ffffff;
font-weight: 700;
font-size: 16px;
color: #a8aaaf;
text-decoration: none;
"
>
Overseerr
</a>
</td>
</tr>
<tr>
<td style="width: 100%" width="100%">
<table
align="center"
class="sm-w-full"
style="
background-color: #ffffff;
margin-left: auto;
margin-right: auto;
width: 570px;
"
width="570"
bgcolor="#ffffff"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td style="padding: 45px">
<div style="font-size: 16px">
{{body}}
<br />
<br />
<p style="margin-top: 4px; text-align: center">
{{media_name}
</p>
<table
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td>
<table
cellpadding="0"
cellspacing="0"
role="presentation"
>
<img src="{{image_url}}" alt="" />
<p></p>
</table>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p
style="
font-size: 16px;
line-height: 24px;
margin-top: 6px;
margin-bottom: 20px;
color: #51545e;
"
>
Requested by {{requester_name}} at {{timestamp}}
</p>
<p
style="
font-size: 13px;
line-height: 24px;
margin-top: 6px;
margin-bottom: 20px;
color: #51545e;
"
>
<a href="{{action_url}}" style="color: #3869d4"
>Open detail page</a
>
</p>
</td>
</tr>
</table>
</div>
<tr>
<td>
<table
align="center"
class="sm-w-full"
style="
margin-left: auto;
margin-right: auto;
text-align: center;
width: 570px;
"
width="570"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td align="center" style="font-size: 16px; padding: 45px">
<p
style="
font-size: 13px;
line-height: 24px;
margin-top: 6px;
margin-bottom: 20px;
text-align: center;
color: #a8aaaf;
"
>
Overseerr.
</p>
</td>
</tr>
</table>
</td>
</tr>
</body>
</html>

View File

@@ -1 +1 @@
= `${requestType}: ${mediaName} - Overseerr`
= `${requestType}: ${mediaName} - ${applicationTitle}`

View File

@@ -54,7 +54,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
color: #a8aaaf;\
text-decoration: none;\
')
| Overseerr
| #{applicationTitle}
tr
td(style='width: 100%' width='100%')
table.sm-w-full(align='center' style='\
@@ -76,7 +76,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
margin-bottom: 20px;\
color: #51545e;\
')
a(href=applicationUrl style='color: #3869d4') Open Overseerr
a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle}
tr
td
table.sm-w-full(align='center' style='\
@@ -95,4 +95,4 @@ tr
text-align: center;\
color: #a8aaaf;\
')
| Overseerr.
| #{applicationTitle}

View File

@@ -1 +1 @@
= `Password reset - Overseerr`
= `Password Reset - ${applicationTitle}`

View File

@@ -54,7 +54,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
color: #a8aaaf;\
text-decoration: none;\
')
| Overseerr
| #{applicationTitle}
tr
td(style='width: 100%' width='100%')
table.sm-w-full(align='center' style='\
@@ -74,7 +74,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
margin-bottom: 20px;\
color: #51545e;\
')
a(href=applicationUrl style='color: #3869d4') Open Overseerr
a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle}
tr
td
table.sm-w-full(align='center' style='\
@@ -93,4 +93,4 @@ tr
text-align: center;\
color: #a8aaaf;\
')
| Overseerr.
| #{applicationTitle}

View File

@@ -1 +1 @@
= `Test Notification - Overseerr`
= `Test Notification - ${applicationTitle}`

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