Compare commits

...

44 Commits

Author SHA1 Message Date
Jonas F
ee7e91c7c9 fix: change SeriesSearch to MissingEpisodeSearch for season requests (#711)
This fix changes the behavior of how Overseerr requests series data from Sonarr. Previously, when adding new seasons to a partially available series, Overseerr would initiate a SeriesSearch, causing Sonarr to search for all monitored seasons of the series, including those already available. This behavior is now corrected by executing a MissingEpisodeSearchCommand for the specific seriesId, which aligns with the intended behavior of only searching for and adding the newly requested seasons that are not already available.

Resolves: https://github.com/Fallenbagel/jellyseerr/issues/710
2024-09-16 22:07:43 +05:00
Aidan Hilt
45ef150e36 feat: add environment variable for API key (#831)
* Added the ability to set the API key with the env var API_KEY

* Adding debug statements

* Updating

* feat: adding env var for API key

* feat: update

* fix(settings/index.ts): remove a print statement that logs the API key to the console

* Update en.json

* docs: added documentation about API_KEY environment variable

* feat: add a check to ensure API key always uses env var if provided

* feat: always check the API_KEY env var at startup

* chore: add back the gitkeeps under ./config, accidentally deleted in prev commit

* chore: revert change made to docker-compose that was accidentally committed
2024-09-16 19:05:44 +02:00
Fallenbagel
54cfeefe74 docs(readme): update the translate badge (#951) 2024-08-28 21:58:04 +05:00
Gauthier
89e0a831ec fix: add an error message to say when an email is already taken (#947)
When the email is modified in the user settings and it is already taken by someone else, a generic
message saying that something wrong happened, without saying that it is because the email is already
taken by another user. This PR adds this error message for the email.
2024-08-27 12:54:56 +02:00
Joaquin Olivero
e57d2654d1 fix: set correct user type when importing from emby (#949)
fix #948

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-08-26 22:14:46 +02:00
Gauthier
a02a0dd176 docs: fix broken anchors (#946) 2024-08-26 17:27:06 +05:00
Gauthier
7423bbbffc fix(setup): page display when homepage is loading (#940)
* fix: remove an unwanted display of the setup page while the homepage is loading

* fix: edit incorrect return type of setup page
2024-08-22 17:46:06 +05:00
Fallenbagel
32343f23a3 chore(migrations): proper rename & clean up of media server type migration (#939) 2024-08-22 14:37:41 +02:00
Fallenbagel
15cb949f1f feat: Jellyfin/Emby server type setup (#685)
* feat: add Media Server Selection to Setup Page

Introduce the ability to select the media server type on the setup page. Users can now choose their
preferred media server (e.g., Plex through the Plex sign-in or Emby/Jellyfin sign-in to select
either Emby or Jellyfin). The selected media server type is then reflected in the application
settings. This enhancement provides users with increased flexibility and customization options
during the initial setup process, eliminating the need to rely on environment variables (which
cannot be set if using platforms like snaps). Existing Emby users, who use the environment variable,
should log out and log back in after updating to set their mediaServerType to Emby.

BREAKING CHANGE: This commit deprecates the JELLYFIN_TYPE variable to identify Emby media server and
instead rely on the mediaServerType that is set in the `settings.json`. Existing environment
variable users can log out and log back in to set the mediaServerType to `3` (Emby).

* feat(api): add severType to the api

BREAKING CHANGE: This adds a serverType to the `/auth/jellyfin` which requires a serverType to be
set (`jellyfin`/`emby`)

* refactor: use enums for serverType and rename selectedservice to serverType

* refactor(auth): jellyfin/emby authentication to set MediaServerType

* fix: issue page formatMessage for 4k media

* refactor: cleaner way of handling serverType change using MediaServerType instead of strings

instead of using strings now it will use MediaServerType enums for serverType

* revert: removed conditional render of the auto-request permission

reverts the conditional render toshow the auto-request permission if the mediaServerType was set to
Plex as this should be handled in a different PR and Cypress tests should be modified
accordingly(currently cypress test would fail if this conditional check is there)

* feat: add server type step to setup

* feat: migrate existing emby setups to use emby mediaServerType

* fix: scan jobs not running when media server type is emby

* fix: emby media server type migration

* refactor: change emby logo to full logo

* style: decrease emby logo size in setup screen

* refactor: use title case for servertype i18n message

* refactor(i18n): fix a typo

* refactor: use enums instead of numbers

* fix: remove old references to JELLYFIN_TYPE environment variable

* fix: go back to the last step when refresh the setup page

* fix: move "scanning in background" tip next to the scanning section

* fix: redirect the setup page when Jellyseerr is already setup

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2024-08-21 02:35:47 +05:00
Joaquin Olivero
cfd1bc2535 feat: adds status filter for tv shows (#796)
re #605

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-08-19 23:37:04 +05:00
Gauthier
80f63017ac fix: handle status badge for season packs (#927)
* fix: handle status badge for season packs

When a series is downloaded with a season pack, the status tooltip displays only the name of the
first episode as a title, and displays a list of all episodes as a description, with the same file
being repeated for each episode. This PR fixes this, using the season number as the tooltip title
and showing only the season pack file currently being downloaded.

* fix: add missing i18n translation
2024-08-19 09:02:52 +05:00
jellyseerr-weblate
0c7e652672 refactor(i18n): merge weblate (#934)
* Added translation using Weblate (Slovenian)

* Translated using Weblate (German)
Currently translated at 93.1% (1216 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/de/

* Translated using Weblate (Slovenian)
Currently translated at 4.1% (54 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/sl/

* Translated using Weblate (Dutch)
Currently translated at 99.4% (1299 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/nl/

* Added translation using Weblate (Turkish)

* Translated using Weblate (Turkish)
Currently translated at 7.2% (95 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/tr/

* Translated using Weblate (German)
Currently translated at 100.0% (1306 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/de/

* Translated using Weblate (Romanian)
Currently translated at 33.5% (438 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/ro/

* Translated using Weblate (Russian)
Currently translated at 96.6% (1262 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/ru/

* Translated using Weblate (Russian)
Currently translated at 97.4% (1273 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/ru/

* Translated using Weblate (Russian)
Currently translated at 100.0% (1306 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/ru/

* Translated using Weblate (Dutch)
Currently translated at 100.0% (1306 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/nl/

* Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (1306 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/zh_Hans/

* Translated using Weblate (Spanish)
Currently translated at 100.0% (1306 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/es/

* Translated using Weblate (French)
Currently translated at 100.0% (1306 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/fr/

* Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.0% (1254 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/pt_BR/

* Translated using Weblate (Hebrew)
Currently translated at 15.2% (199 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/he/

* Translated using Weblate (Polish)
Currently translated at 82.6% (1079 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/pl/

* Translated using Weblate (Polish)
Currently translated at 83.9% (1096 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/pl/

* Translated using Weblate (Spanish)
Currently translated at 100.0% (1306 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/es/

* Translated using Weblate (Ukrainian)
Currently translated at 93.7% (1225 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/uk/

* Translated using Weblate (Ukrainian)
Currently translated at 94.3% (1232 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/uk/

* Translated using Weblate (Ukrainian)
Currently translated at 94.3% (1232 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/uk/

* Translated using Weblate (Polish)
Currently translated at 84.1% (1099 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/pl/

* Translated using Weblate (Ukrainian)
Currently translated at 99.8% (1304 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/uk/

* Translated using Weblate (Ukrainian)
Currently translated at 99.8% (1304 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/uk/

* Translated using Weblate (Ukrainian)
Currently translated at 99.8% (1304 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/uk/

* Translated using Weblate (Catalan)
Currently translated at 94.1% (1230 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/ca/

* Translated using Weblate (Ukrainian)
Currently translated at 99.8% (1304 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/uk/

* Translated using Weblate (Hebrew)
Currently translated at 23.0% (301 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/he/

* Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (1306 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/zh_Hans/

* Translated using Weblate (Hebrew)
Currently translated at 26.4% (346 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/he/

* Translated using Weblate (German)
Currently translated at 100.0% (1306 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/de/

* Translated using Weblate (Polish)
Currently translated at 94.4% (1233 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/pl/

* Translated using Weblate (Turkish)
Currently translated at 19.2% (252 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/tr/

* Translated using Weblate (Slovenian)
Currently translated at 4.2% (56 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/sl/

* Translated using Weblate (Turkish)
Currently translated at 42.7% (558 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/tr/

* Translated using Weblate (Turkish)
Currently translated at 44.6% (583 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/tr/

* Translated using Weblate (Turkish)
Currently translated at 75.6% (988 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/tr/

* Translated using Weblate (Turkish)
Currently translated at 85.0% (1111 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/tr/

* Translated using Weblate (Turkish)
Currently translated at 100.0% (1306 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/tr/

* Translated using Weblate (Swedish)
Currently translated at 99.7% (1303 of 1306 strings)
Translate-URL: http://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/sv/

* style(i18n): ran prettier

* style(i18n): ran prettier

---------

Co-authored-by: Boštjan KOLAR <civywl@users.noreply.jellyseerr.borgcube.de>
Co-authored-by: Alex F <weblate@xathon.de>
Co-authored-by: Bas <910100490+weblate@proton.me>
Co-authored-by: N/A <me@puffin.icu>
Co-authored-by: Ramon Stohr <ramonstohr@gmail.com>
Co-authored-by: Cosmin Mocan <cosmin_mocan@hotmail.com>
Co-authored-by: Aleksandr <AlexZagric@users.noreply.jellyseerr.borgcube.de>
Co-authored-by: Aleksandr <alexzag2004@gmail.com>
Co-authored-by: Bas Muldder <bas.d.mulder@gmail.com>
Co-authored-by: 宿命 <331874545@qq.com>
Co-authored-by: Eduard Perez Mendez <eduardperezmendez@gmail.com>
Co-authored-by: Quack6765 <weblate@po-mail.com>
Co-authored-by: grayair <grayair@proton.me>
Co-authored-by: osh <osh@osh.cc>
Co-authored-by: uqlel <jellyseer.borgcube.de@uqlel.ovh>
Co-authored-by: Flashk <mevengar@gmail.com>
Co-authored-by: michael <michaelvelosk@gmail.com>
Co-authored-by: A a <arnau2106@gmail.com>
Co-authored-by: Albert Einstien <dbig350@gmail.com>
Co-authored-by: C W <the-eggs@163.com>
Co-authored-by: Nir Israel Hen <nirisraelh@gmail.com>
Co-authored-by: Adrian Konopczynski <adrikonop@anomalie.ga>
Co-authored-by: Wiktor Kowalski <a1opnxgtr@mozmail.com>
Co-authored-by: Jakob Števanec <jakolin98.windowslive@gmail.com>
Co-authored-by: Mattias Magnusson <mattish.91@gmail.com>
Co-authored-by: fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
2024-08-16 23:02:16 +05:00
Gauthier
bd4da6d5fc feat(jellyfinapi): switch to API tokens instead of auth tokens (#868)
* feat(jellyfinapi): create Jellyfin API key from admin user

* fix(jellyfinapi): add migration script for Jellyfin API key

* feat(jellyfinapi): use Jellyfin API key instead of admin auth token

* fix(jellyfinapi): fix api key migration

* feat(jellyfinapi): add API key field to Jellyfin settings

* fix: move the API key field in the Jellyfin settings
2024-08-13 19:01:45 +05:00
Gauthier
12f908de7f fix(tmdb): fallback movie/show overview to English when none is available in requested locale (#928)
This PR adds a second call to TMDB to retried the overview in English if no overview is available in
the requested locale

fix #925
2024-08-13 10:41:59 +02:00
Gauthier
61dcd8e487 fix: update the filter removing existing users from Jellyfin import modal (#924)
Currently import button sometimes shows already imported users and this would break it if an admin
tries to import an already imported user.
2024-08-11 19:25:17 +02:00
Gauthier
9aee8887d3 fix: rewrite request from axios to Fetch (#920) 2024-08-07 14:33:44 +02:00
Joaquin Olivero
2348f23f43 feat: Option on item's page to add/remove from watchlist (#781)
* feat: adds button on the page of a media item to add or remove it from a user's watchlist

re #730

* fix: whitespace and i18n key

* style: fix code format to the required standards

* refactor: change axios for the fetch api

---------

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-08-07 13:46:57 +02:00
Gauthier
74a2d25f15 fix(api): handle non-existent ratings on IMDb (#822) 2024-08-05 14:30:27 +02:00
Fallenbagel
a2c2d261fc docs(windows): add win-node-env instructions to develop build from source (#918) 2024-08-05 17:19:06 +05:00
Fallenbagel
71acfb1b1f docs(windows): add missing win-node-env dependency in the installation steps (#912) 2024-08-05 17:13:22 +05:00
allcontributors[bot]
29a32d0391 docs: add myselfolli as a contributor for code (#917)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-08-05 17:08:21 +05:00
allcontributors[bot]
f7be4789a2 docs: add franciscofsales as a contributor for code (#916)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-08-05 17:07:29 +05:00
allcontributors[bot]
181cb19048 docs: add XDark187 as a contributor for code (#915)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-08-05 17:06:58 +05:00
allcontributors[bot]
32c77f9e94 docs: add mobihen as a contributor for translation (#913)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-08-05 17:05:26 +05:00
fallenbagel
b43c1e350e chore(codeowners): add gauthier-th as a codeowner 2024-08-05 16:46:08 +05:00
Oliver Laing
64453320d3 feat: show quality profile on request (#847)
* feat: backend fetch and return quality profile

* feat: show request profile name

* fix: wrong backend types

* feat: i18n keys

* fix: don't display quality profile if not set

* fix: remove development artifact

* fix: reduce parent div padding
2024-08-01 14:59:45 +02:00
Gauthier
36d98a2681 fix: add missing parameter to delete requests from ExternalAPI (#904)
fix #903
2024-07-30 00:43:40 +02:00
Gauthier
d5f817e734 fix: remove email requirement for the user, and use the username if no email provided (#900)
* fix: remove email requirement for the user, and use the username if no email provided

* fix: update translations

* fix: remove useless console.log

* test: fix user list test

* fix: disallow Plex users from changing their email
2024-07-29 21:27:31 +02:00
Gauthier
422085523e fix: resize header image in network and studio pages (#902) 2024-07-29 16:49:51 +02:00
Gauthier
fccfca6ed0 fix: enhance error messages when Fetch API fails (#893) 2024-07-27 01:43:16 +02:00
Gauthier
3fc14c9e22 fix: rewrite the rate limit utility (#896) 2024-07-26 21:12:41 +05:00
Fallenbagel
62dbde448c revert: fix(api): fix nextjs error handler (#882) (#892)
This commit reverts the nextjs error handler fix that was introduced in #882 as that change requires
further refactor which should be held off for another version owing to the fact that there are
currently a lot of changes ready for the next version of jellyseerr.
2024-07-25 16:48:29 +05:00
Gauthier
0116c13e06 fix(api): fix nextjs error handler (#882)
This PR removes a custom error handler that sometimes caused issues by sending headers after some
content had already been sent.
2024-07-24 21:31:18 +02:00
Nir Israel Hen
c96ca6742e feat(translation): added full Hebrew translation (#871)
* feat(translation): added full Hebrew translation

* Update he.json

fixed missing translations
2024-07-24 23:51:55 +05:00
Gauthier
c80d9a853a fix: remove protocol-relative URLs from next/image (#889)
Next.js image component doesn't support protocol-relative URLs, so this PR replaces them to https
URLs
2024-07-24 20:10:31 +02:00
Gauthier
6cea8bba59 fix: add missing brackets (#888) 2024-07-24 22:14:04 +05:00
Fallenbagel
2be9c7dcc1 fix: add missing content-type header (#887)
* fix: add missing headers when commenting on an issue

* fix: more missing content-type headers in post requests
2024-07-24 19:34:54 +05:00
Gauthier
5cc4389825 fix(api): save new password when reset password of local account (#886) 2024-07-24 15:44:10 +02:00
Baraa
dd6dbf1de9 style(scrollbar): changed the scrollbar to be a bit thin and darker to match the theme of jellyseerr (#857)
fix #856
2024-07-21 23:37:25 +02:00
Francisco Sales
c600566ac0 fix: length of undefined on users warnings (#875) 2024-07-21 23:37:01 +02:00
Joaquin Olivero
4db1df2ba5 refactor: auto close manage slide on media delete (#877)
* refactor: auto close manage slide on media delete

re #841

* fix: close manage slide-over when removing media item from *arr

---------

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-07-21 23:36:33 +02:00
Gauthier
3a363ae1ff feat: update Plex logo (#884) 2024-07-21 23:36:02 +02:00
Gauthier
084e1b224e fix: add missing header when creating an issue (#879) 2024-07-17 00:02:07 +02:00
Gauthier
b36bb3fa58 refactor: switch from Axios for Fetch API (#840)
* refactor: switch ExternalAPI to Fetch API

* fix: add missing auth token in Plex request

* fix: send proper URL params

* ci: try to fix format checker

* ci: ci: try to fix format checker

* ci: try to fix format checker

* refactor: make tautulli use the ExternalAPI class

* refactor: add rate limit to fetch api

* refactor: add rate limit to fetch api

* refactor: switch server from axios to fetch api

* refactor: switch frontend from axios to fetch api

* fix: switch from URL objects to strings

* fix: use the right search params for ExternalAPI

* fix: better log for ExternalAPI errors

* feat: add retry to external API requests

* fix: try to fix network errors with IPv6

* fix: imageProxy rate limit

* revert: remove retry to external API requests

* feat: set IPv4 first as an option

* fix(jellyfinapi): add missing argument in JellyfinAPI constructor

* refactor: clean the rate limit utility
2024-07-14 19:04:36 +02:00
161 changed files with 11162 additions and 13094 deletions

View File

@@ -403,6 +403,42 @@
"contributions": [ "contributions": [
"doc" "doc"
] ]
},
{
"login": "mobihen",
"name": "Nir Israel Hen",
"avatar_url": "https://avatars.githubusercontent.com/u/35529491?v=4",
"profile": "https://mobihen.com",
"contributions": [
"translation"
]
},
{
"login": "XDark187",
"name": "Baraa",
"avatar_url": "https://avatars.githubusercontent.com/u/39034192?v=4",
"profile": "https://github.com/XDark187",
"contributions": [
"code"
]
},
{
"login": "franciscofsales",
"name": "Francisco Sales",
"avatar_url": "https://avatars.githubusercontent.com/u/7977645?v=4",
"profile": "https://github.com/franciscofsales",
"contributions": [
"code"
]
},
{
"login": "myselfolli",
"name": "Oliver Laing",
"avatar_url": "https://avatars.githubusercontent.com/u/37535998?v=4",
"profile": "https://github.com/myselfolli",
"contributions": [
"code"
]
} }
] ]
} }

2
.github/CODEOWNERS vendored
View File

@@ -1,2 +1,2 @@
# Global code ownership # Global code ownership
* @Fallenbagel * @Fallenbagel @gauthier-th

View File

@@ -8,3 +8,4 @@ pnpm-lock.yaml
# assets # assets
src/assets/ src/assets/
public/ public/
docs/

View File

@@ -3,6 +3,12 @@ module.exports = {
singleQuote: true, singleQuote: true,
trailingComma: 'es5', trailingComma: 'es5',
overrides: [ overrides: [
{
files: 'pnpm-lock.yaml',
options: {
rangeEnd: 0, // default: Infinity
},
},
{ {
files: 'gen-docs/pnpm-lock.yaml', files: 'gen-docs/pnpm-lock.yaml',
options: { options: {

View File

@@ -8,10 +8,10 @@
<p align="center"> <p align="center">
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a> <a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a>
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a> <a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a> <a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a> <a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- 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-40-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. **Jellyseerr** is a free and open source software application for managing requests for your media library.
@@ -137,6 +137,15 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -1,5 +1,5 @@
const testUser = { const testUser = {
displayName: 'Test User', username: 'Test User',
emailAddress: 'test@seeerr.dev', emailAddress: 'test@seeerr.dev',
password: 'test1234', password: 'test1234',
}; };
@@ -32,7 +32,7 @@ describe('User List', () => {
cy.get('[data-testid=modal-title]').should('contain', 'Create Local User'); cy.get('[data-testid=modal-title]').should('contain', 'Create Local User');
cy.get('#displayName').type(testUser.displayName); cy.get('#username').type(testUser.username);
cy.get('#email').type(testUser.emailAddress); cy.get('#email').type(testUser.emailAddress);
cy.get('#password').type(testUser.password); cy.get('#password').type(testUser.password);

View File

@@ -99,6 +99,9 @@ PORT=5055
## Uncomment if your media server is emby instead of jellyfin. ## Uncomment if your media server is emby instead of jellyfin.
# JELLYFIN_TYPE=emby # JELLYFIN_TYPE=emby
## Uncomment if you want to force Node.js to resolve IPv4 before IPv6 (advanced users only)
# FORCE_IPV4_FIRST=true
``` ```
2. Then run the following commands: 2. Then run the following commands:
```bash ```bash
@@ -245,6 +248,7 @@ git checkout main
``` ```
3. Install the dependencies: 3. Install the dependencies:
```powershell ```powershell
npm install -g win-node-env
set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000 set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000
``` ```
4. Build the project: 4. Build the project:
@@ -269,6 +273,7 @@ git checkout develop # by default, you are on the develop branch so this step is
``` ```
3. Install the dependencies: 3. Install the dependencies:
```powershell ```powershell
npm install -g win-node-env
set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile
``` ```
4. Build the project: 4. Build the project:

View File

@@ -22,7 +22,7 @@ export const VersionMismatchWarning = () => {
<> <>
{!isUpToDate ? ( {!isUpToDate ? (
<Admonition type="warning"> <Admonition type="warning">
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package">override the package derivation</a>. The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package-derivation">override the package derivation</a>.
</Admonition> </Admonition>
) : ( ) : (
<Admonition type="success"> <Admonition type="success">

View File

@@ -12,6 +12,8 @@ This is your Jellyseerr API key, which can be used to integrate Jellyseerr with
If you need to generate a new API key for any reason, simply click the button to the right of the text box. If you need to generate a new API key for any reason, simply click the button to the right of the text box.
If you want to set the API key, rather than letting it be randomly generated, you can use the API_KEY environment variable. Whatever that variable is set to will be your API key.
## Application Title ## Application Title
If you aren't a huge fan of the name "Jellyseerr" and would like to display something different to your users, you can customize the application title! If you aren't a huge fan of the name "Jellyseerr" and would like to display something different to your users, you can customize the application title!

View File

@@ -35,7 +35,7 @@ Users can override the [global display language](/using-jellyseerr/settings/gene
### Discover Region & Discover Language ### Discover Region & Discover Language
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-and-discover-language) to suit their own preferences. Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region--discover-language) to suit their own preferences.
### Movie Request Limit & Series Request Limit ### Movie Request Limit & Series Request Limit

View File

@@ -4,10 +4,7 @@
module.exports = { module.exports = {
env: { env: {
commitTag: process.env.COMMIT_TAG || 'local', commitTag: process.env.COMMIT_TAG || 'local',
}, forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
publicRuntimeConfig: {
// Will be available on both server and client
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
}, },
images: { images: {
remotePatterns: [ remotePatterns: [

View File

@@ -3586,6 +3586,8 @@ paths:
type: string type: string
email: email:
type: string type: string
serverType:
type: number
required: required:
- username - username
- password - password
@@ -4862,6 +4864,11 @@ paths:
schema: schema:
type: string type: string
example: 8|9 example: 8|9
- in: query
name: status
schema:
type: string
example: 3|4
responses: responses:
'200': '200':
description: Results description: Results

View File

@@ -43,8 +43,6 @@
"@svgr/webpack": "6.5.1", "@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30", "@tanem/react-nprogress": "5.0.30",
"ace-builds": "1.15.2", "ace-builds": "1.15.2",
"axios": "1.3.4",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.11.0", "bowser": "2.11.0",
"connect-typeorm": "1.1.4", "connect-typeorm": "1.1.4",
@@ -121,7 +119,7 @@
"@types/express": "4.17.17", "@types/express": "4.17.17",
"@types/express-session": "1.17.6", "@types/express-session": "1.17.6",
"@types/lodash": "4.14.191", "@types/lodash": "4.14.191",
"@types/node": "17.0.36", "@types/node": "20.14.8",
"@types/node-schedule": "2.1.0", "@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",

12799
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios'; import fs, { promises as fsp } from 'node:fs';
import fs, { promises as fsp } from 'fs'; import path from 'node:path';
import path from 'path'; import { Readable } from 'node:stream';
import type { ReadableStream } from 'node:stream/web';
import xml2js from 'xml2js'; import xml2js from 'xml2js';
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
@@ -161,13 +162,18 @@ class AnimeListMapping {
label: 'Anime-List Sync', label: 'Anime-List Sync',
}); });
try { try {
const response = await axios.get(MAPPING_URL, { const response = await fetch(MAPPING_URL);
responseType: 'stream', if (!response.ok) {
}); throw new Error(`Failed to fetch: ${response.statusText}`);
await new Promise<void>((resolve) => { }
await new Promise<void>((resolve, reject) => {
const writer = fs.createWriteStream(LOCAL_PATH); const writer = fs.createWriteStream(LOCAL_PATH);
writer.on('finish', resolve); writer.on('finish', resolve);
response.data.pipe(writer); writer.on('error', reject);
if (!response.body) return reject();
Readable.fromWeb(response.body as ReadableStream<Uint8Array>).pipe(
writer
);
}); });
} catch (e) { } catch (e) {
throw new Error(`Failed to download Anime-List mapping: ${e.message}`); throw new Error(`Failed to download Anime-List mapping: ${e.message}`);

View File

@@ -1,6 +1,5 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import type { RateLimitOptions } from '@server/utils/rateLimit';
import axios from 'axios'; import rateLimit from '@server/utils/rateLimit';
import rateLimit from 'axios-rate-limit';
import type NodeCache from 'node-cache'; import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds) // 5 minute default TTL (in seconds)
@@ -12,71 +11,87 @@ const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions { interface ExternalAPIOptions {
nodeCache?: NodeCache; nodeCache?: NodeCache;
headers?: Record<string, unknown>; headers?: Record<string, unknown>;
rateLimit?: { rateLimit?: RateLimitOptions;
maxRPS: number;
maxRequests: number;
};
} }
class ExternalAPI { class ExternalAPI {
protected axios: AxiosInstance; protected fetch: typeof fetch;
protected params: Record<string, string>;
protected defaultHeaders: { [key: string]: string };
private baseUrl: string; private baseUrl: string;
private cache?: NodeCache; private cache?: NodeCache;
constructor( constructor(
baseUrl: string, baseUrl: string,
params: Record<string, unknown>, params: Record<string, string> = {},
options: ExternalAPIOptions = {} options: ExternalAPIOptions = {}
) { ) {
this.axios = axios.create({
baseURL: baseUrl,
params,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
});
if (options.rateLimit) { if (options.rateLimit) {
this.axios = rateLimit(this.axios, { this.fetch = rateLimit(fetch, options.rateLimit);
maxRequests: options.rateLimit.maxRequests, } else {
maxRPS: options.rateLimit.maxRPS, this.fetch = fetch;
});
} }
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.params = params;
this.defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
};
this.cache = options.nodeCache; this.cache = options.nodeCache;
} }
protected async get<T>( protected async get<T>(
endpoint: string, endpoint: string,
config?: AxiosRequestConfig, params?: Record<string, string>,
ttl?: number ttl?: number,
config?: RequestInit
): Promise<T> { ): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params); const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey); const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) { if (cachedItem) {
return cachedItem; return cachedItem;
} }
const response = await this.axios.get<T>(endpoint, config); const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) { if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
} }
return response.data; return data;
} }
protected async post<T>( protected async post<T>(
endpoint: string, endpoint: string,
data: Record<string, unknown>, data?: Record<string, unknown>,
config?: AxiosRequestConfig, params?: Record<string, string>,
ttl?: number ttl?: number,
config?: RequestInit
): Promise<T> { ): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, { const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params, config: { ...this.params, ...params },
data, data,
}); });
const cachedItem = this.cache?.get<T>(cacheKey); const cachedItem = this.cache?.get<T>(cacheKey);
@@ -84,21 +99,117 @@ class ExternalAPI {
return cachedItem; return cachedItem;
} }
const response = await this.axios.post<T>(endpoint, data, config); const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'POST',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) { if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
} }
return response.data; return resData;
}
protected async put<T>(
endpoint: string,
data: Record<string, unknown>,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: { ...this.params, ...params },
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'PUT',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
return resData;
}
protected async delete<T>(
endpoint: string,
params?: Record<string, string>,
config?: RequestInit
): Promise<T> {
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'DELETE',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
return data;
} }
protected async getRolling<T>( protected async getRolling<T>(
endpoint: string, endpoint: string,
config?: AxiosRequestConfig, params?: Record<string, string>,
ttl?: number ttl?: number,
config?: RequestInit,
overwriteBaseUrl?: string
): Promise<T> { ): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params); const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey); const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) { if (cachedItem) {
@@ -109,20 +220,78 @@ class ExternalAPI {
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 < keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER Date.now() - DEFAULT_ROLLING_BUFFER
) { ) {
this.axios.get<T>(endpoint, config).then((response) => { const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
}).then(async (response) => {
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${
text ? ': ' + text : ''
}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}); });
} }
return cachedItem; return cachedItem;
} }
const response = await this.axios.get<T>(endpoint, config); const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) { if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
} }
return response.data; return data;
}
private formatUrl(
endpoint: string,
params?: Record<string, string>,
overwriteBaseUrl?: string
): string {
const baseUrl = overwriteBaseUrl || this.baseUrl;
const href =
baseUrl +
(baseUrl.endsWith('/') ? '' : '/') +
(endpoint.startsWith('/') ? endpoint.slice(1) : endpoint);
const searchParams = new URLSearchParams({
...this.params,
...params,
});
return (
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
} }
private serializeCacheKey( private serializeCacheKey(
@@ -135,6 +304,29 @@ class ExternalAPI {
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`; return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
} }
private async getDataFromResponse(response: Response) {
const contentType = response.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
return await response.json();
} else if (
contentType?.includes('application/xml') ||
contentType?.includes('text/html') ||
contentType?.includes('text/plain')
) {
return await response.text();
} else {
try {
return await response.json();
} catch {
try {
return await response.blob();
} catch {
return null;
}
}
}
}
} }
export default ExternalAPI; export default ExternalAPI;

View File

@@ -1,6 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache'; import cacheManager from '@server/lib/cache';
import logger from '@server/logger'; import logger from '@server/logger';
import ExternalAPI from './externalapi';
interface GitHubRelease { interface GitHubRelease {
url: string; url: string;
@@ -67,10 +67,6 @@ class GithubAPI extends ExternalAPI {
'https://api.github.com', 'https://api.github.com',
{}, {},
{ {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('github').data, nodeCache: cacheManager.getCache('github').data,
} }
); );
@@ -85,9 +81,7 @@ class GithubAPI extends ExternalAPI {
const data = await this.get<GitHubRelease[]>( const data = await this.get<GitHubRelease[]>(
'/repos/fallenbagel/jellyseerr/releases', '/repos/fallenbagel/jellyseerr/releases',
{ {
params: { per_page: take.toString(),
per_page: take,
},
} }
); );
@@ -112,10 +106,8 @@ class GithubAPI extends ExternalAPI {
const data = await this.get<GithubCommit[]>( const data = await this.get<GithubCommit[]>(
'/repos/fallenbagel/jellyseerr/commits', '/repos/fallenbagel/jellyseerr/commits',
{ {
params: { per_page: take.toString(),
per_page: take, branch,
branch,
},
} }
); );

View File

@@ -93,9 +93,7 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
} }
class JellyfinAPI extends ExternalAPI { class JellyfinAPI extends ExternalAPI {
private authToken?: string;
private userId?: string; private userId?: string;
private jellyfinHost: string;
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
let authHeaderVal: string; let authHeaderVal: string;
@@ -111,14 +109,9 @@ class JellyfinAPI extends ExternalAPI {
{ {
headers: { headers: {
'X-Emby-Authorization': authHeaderVal, 'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
}, },
} }
); );
this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
} }
public async login( public async login(
@@ -127,7 +120,7 @@ class JellyfinAPI extends ExternalAPI {
ClientIP?: string ClientIP?: string
): Promise<JellyfinLoginResponse> { ): Promise<JellyfinLoginResponse> {
const authenticate = async (useHeaders: boolean) => { const authenticate = async (useHeaders: boolean) => {
const headers = const headers: { [key: string]: string } =
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {}; useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
return this.post<JellyfinLoginResponse>( return this.post<JellyfinLoginResponse>(
@@ -136,6 +129,8 @@ class JellyfinAPI extends ExternalAPI {
Username, Username,
Pw: Password, Pw: Password,
}, },
{},
undefined,
{ headers } { headers }
); );
}; };
@@ -152,7 +147,7 @@ class JellyfinAPI extends ExternalAPI {
try { try {
return await authenticate(false); return await authenticate(false);
} catch (e) { } catch (e) {
const status = e.response?.status; const status = e.cause?.status;
const networkErrorCodes = new Set([ const networkErrorCodes = new Set([
'ECONNREFUSED', 'ECONNREFUSED',
@@ -190,7 +185,7 @@ class JellyfinAPI extends ExternalAPI {
return systemInfoResponse; return systemInfoResponse;
} catch (e) { } catch (e) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -207,7 +202,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.response?.status, ApiErrorCode.Unknown); throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
} }
} }
@@ -222,7 +217,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -238,7 +233,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -296,7 +291,16 @@ class JellyfinAPI extends ExternalAPI {
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> { public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const libraryItemsResponse = await this.get<any>( const libraryItemsResponse = await this.get<any>(
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false` `/Users/${this.userId}/Items`,
{
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Series,Movie,Others',
Recursive: 'true',
StartIndex: '0',
ParentId: id,
collapseBoxSetItems: 'false',
}
); );
return libraryItemsResponse.Items.filter( return libraryItemsResponse.Items.filter(
@@ -308,14 +312,18 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> { public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const itemResponse = await this.get<any>( const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}` `/Users/${this.userId}/Items/Latest`,
{
Limit: '12',
ParentId: id,
}
); );
return itemResponse; return itemResponse;
@@ -325,7 +333,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -340,7 +348,7 @@ class JellyfinAPI extends ExternalAPI {
return itemResponse; return itemResponse;
} catch (e) { } catch (e) {
if (availabilitySync.running) { if (availabilitySync.running) {
if (e.response && e.response.status === 500) { if (e.cause?.status === 500) {
return undefined; return undefined;
} }
} }
@@ -349,7 +357,7 @@ class JellyfinAPI extends ExternalAPI {
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -364,7 +372,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -374,7 +382,10 @@ class JellyfinAPI extends ExternalAPI {
): Promise<JellyfinLibraryItem[]> { ): Promise<JellyfinLibraryItem[]> {
try { try {
const episodeResponse = await this.get<any>( const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}` `/Shows/${seriesID}/Episodes`,
{
seasonId: seasonID,
}
); );
return episodeResponse.Items.filter( return episodeResponse.Items.filter(
@@ -386,6 +397,23 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async createApiToken(appName: string): Promise<string> {
try {
await this.post(`/Auth/Keys?App=${appName}`);
const apiKeys = await this.get<any>(`/Auth/Keys`);
return apiKeys.Items.reverse().find(
(item: any) => item.AppName === appName
).AccessToken;
} catch (e) {
logger.error(
`Something went wrong while creating an API key the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }

View File

@@ -1,9 +1,9 @@
import ExternalAPI from '@server/api/externalapi';
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache'; import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import xml2js from 'xml2js'; import xml2js from 'xml2js';
import ExternalAPI from './externalapi';
interface PlexAccountResponse { interface PlexAccountResponse {
user: PlexUser; user: PlexUser;
@@ -137,8 +137,6 @@ class PlexTvAPI extends ExternalAPI {
{ {
headers: { headers: {
'X-Plex-Token': authToken, 'X-Plex-Token': authToken,
'Content-Type': 'application/json',
Accept: 'application/json',
}, },
nodeCache: cacheManager.getCache('plextv').data, nodeCache: cacheManager.getCache('plextv').data,
} }
@@ -149,15 +147,11 @@ class PlexTvAPI extends ExternalAPI {
public async getDevices(): Promise<PlexDevice[]> { public async getDevices(): Promise<PlexDevice[]> {
try { try {
const devicesResp = await this.axios.get( const devicesResp = await this.get('/api/resources', {
'/api/resources?includeHttps=1', includeHttps: '1',
{ });
transformResponse: [],
responseType: 'text',
}
);
const parsedXml = await xml2js.parseStringPromise( const parsedXml = await xml2js.parseStringPromise(
devicesResp.data as DeviceResponse devicesResp as DeviceResponse
); );
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({ return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
name: pxml.$.name, name: pxml.$.name,
@@ -205,11 +199,11 @@ class PlexTvAPI extends ExternalAPI {
public async getUser(): Promise<PlexUser> { public async getUser(): Promise<PlexUser> {
try { try {
const account = await this.axios.get<PlexAccountResponse>( const account = await this.get<PlexAccountResponse>(
'/users/account.json' '/users/account.json'
); );
return account.data.user; return account.user;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from plex.tv: ${e.message}`, `Something went wrong while getting the account from plex.tv: ${e.message}`,
@@ -249,13 +243,10 @@ class PlexTvAPI extends ExternalAPI {
} }
public async getUsers(): Promise<UsersResponse> { public async getUsers(): Promise<UsersResponse> {
const response = await this.axios.get('/api/users', { const data = await this.get('/api/users');
transformResponse: [],
responseType: 'text',
});
const parsedXml = (await xml2js.parseStringPromise( const parsedXml = (await xml2js.parseStringPromise(
response.data data as string
)) as UsersResponse; )) as UsersResponse;
return parsedXml; return parsedXml;
} }
@@ -270,49 +261,49 @@ class PlexTvAPI extends ExternalAPI {
items: PlexWatchlistItem[]; items: PlexWatchlistItem[];
}> { }> {
try { try {
const response = await this.axios.get<WatchlistResponse>( const params = new URLSearchParams({
'/library/sections/watchlist/all', 'X-Plex-Container-Start': offset.toString(),
'X-Plex-Container-Size': size.toString(),
});
const response = await this.fetch(
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
{ {
params: { headers: this.defaultHeaders,
'X-Plex-Container-Start': offset,
'X-Plex-Container-Size': size,
},
baseURL: 'https://metadata.provider.plex.tv',
} }
); );
const data = (await response.json()) as WatchlistResponse;
const watchlistDetails = await Promise.all( const watchlistDetails = await Promise.all(
(response.data.MediaContainer.Metadata ?? []).map( (data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
async (watchlistItem) => { const detailedResponse = await this.getRolling<MetadataResponse>(
const detailedResponse = await this.getRolling<MetadataResponse>( `/library/metadata/${watchlistItem.ratingKey}`,
`/library/metadata/${watchlistItem.ratingKey}`, {},
{ undefined,
baseURL: 'https://metadata.provider.plex.tv', {},
} 'https://metadata.provider.plex.tv'
); );
const metadata = detailedResponse.MediaContainer.Metadata[0]; const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid.find((guid) => const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb') guid.id.startsWith('tmdb')
); );
const tvdbString = metadata.Guid.find((guid) => const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb') guid.id.startsWith('tvdb')
); );
return { return {
ratingKey: metadata.ratingKey, ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be? // This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards // We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1]) ? Number(tvdbString.id.split('//')[1])
: undefined, : undefined,
title: metadata.title, title: metadata.title,
type: metadata.type, type: metadata.type,
}; };
} })
)
); );
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
@@ -320,7 +311,7 @@ class PlexTvAPI extends ExternalAPI {
return { return {
offset, offset,
size, size,
totalSize: response.data.MediaContainer.totalSize, totalSize: data.MediaContainer.totalSize,
items: filteredList, items: filteredList,
}; };
} catch (e) { } catch (e) {

View File

@@ -1,4 +1,4 @@
import ExternalAPI from './externalapi'; import ExternalAPI from '@server/api/externalapi';
interface PushoverSoundsResponse { interface PushoverSoundsResponse {
sounds: { sounds: {
@@ -26,24 +26,13 @@ export const mapSounds = (sounds: {
class PushoverAPI extends ExternalAPI { class PushoverAPI extends ExternalAPI {
constructor() { constructor() {
super( super('https://api.pushover.net/1');
'https://api.pushover.net/1',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
} }
public async getSounds(appToken: string): Promise<PushoverSound[]> { public async getSounds(appToken: string): Promise<PushoverSound[]> {
try { try {
const data = await this.get<PushoverSoundsResponse>('/sounds.json', { const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
params: { token: appToken,
token: appToken,
},
}); });
return mapSounds(data.sounds); return mapSounds(data.sounds);

View File

@@ -155,13 +155,13 @@ export interface IMDBRating {
*/ */
class IMDBRadarrProxy extends ExternalAPI { class IMDBRadarrProxy extends ExternalAPI {
constructor() { constructor() {
super('https://api.radarr.video/v1', { super(
headers: { 'https://api.radarr.video/v1',
'Content-Type': 'application/json', {},
Accept: 'application/json', {
}, nodeCache: cacheManager.getCache('imdb').data,
nodeCache: cacheManager.getCache('imdb').data, }
}); );
} }
/** /**
@@ -175,7 +175,11 @@ class IMDBRadarrProxy extends ExternalAPI {
`/movie/imdb/${IMDBid}` `/movie/imdb/${IMDBid}`
); );
if (!data?.length || data[0].ImdbId !== IMDBid) { if (
!data?.length ||
data[0].ImdbId !== IMDBid ||
!data[0].MovieRatings.Imdb
) {
return null; return null;
} }

View File

@@ -63,15 +63,12 @@ class RottenTomatoes extends ExternalAPI {
super( super(
'https://79frdp12pn-dsn.algolia.net/1/indexes/*', 'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
{ {
'x-algolia-agent': 'x-algolia-agent': 'Algolia for JavaScript (4.14.3); Browser (lite)',
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561', 'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
'x-algolia-application-id': '79FRDP12PN', 'x-algolia-application-id': '79FRDP12PN',
}, },
{ {
headers: { headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-algolia-usertoken': settings.clientId, 'x-algolia-usertoken': settings.clientId,
}, },
nodeCache: cacheManager.getCache('rt').data, nodeCache: cacheManager.getCache('rt').data,

View File

@@ -113,9 +113,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getSystemStatus = async (): Promise<SystemStatus> => { public getSystemStatus = async (): Promise<SystemStatus> => {
try { try {
const response = await this.axios.get<SystemStatus>('/system/status'); const data = await this.get<SystemStatus>('/system/status');
return response.data; return data;
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`[${this.apiName}] Failed to retrieve system status: ${e.message}` `[${this.apiName}] Failed to retrieve system status: ${e.message}`
@@ -157,16 +157,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => { public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try { try {
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>( const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
`/queue`, includeEpisode: 'true',
{ });
params: {
includeEpisode: true,
},
}
);
return response.data.records; return data.records;
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`[${this.apiName}] Failed to retrieve queue: ${e.message}` `[${this.apiName}] Failed to retrieve queue: ${e.message}`
@@ -176,9 +171,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getTags = async (): Promise<Tag[]> => { public getTags = async (): Promise<Tag[]> => {
try { try {
const response = await this.axios.get<Tag[]>(`/tag`); const data = await this.get<Tag[]>(`/tag`);
return response.data; return data;
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`[${this.apiName}] Failed to retrieve tags: ${e.message}` `[${this.apiName}] Failed to retrieve tags: ${e.message}`
@@ -188,11 +183,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public createTag = async ({ label }: { label: string }): Promise<Tag> => { public createTag = async ({ label }: { label: string }): Promise<Tag> => {
try { try {
const response = await this.axios.post<Tag>(`/tag`, { const data = await this.post<Tag>(`/tag`, {
label, label,
}); });
return response.data; return data;
} catch (e) { } catch (e) {
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`); throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
} }
@@ -203,7 +198,7 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
options: Record<string, unknown> options: Record<string, unknown>
): Promise<void> { ): Promise<void> {
try { try {
await this.axios.post(`/command`, { await this.post(`/command`, {
name: commandName, name: commandName,
...options, ...options,
}); });

View File

@@ -37,9 +37,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public getMovies = async (): Promise<RadarrMovie[]> => { public getMovies = async (): Promise<RadarrMovie[]> => {
try { try {
const response = await this.axios.get<RadarrMovie[]>('/movie'); const data = await this.get<RadarrMovie[]>('/movie');
return response.data; return data;
} catch (e) { } catch (e) {
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`); throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`);
} }
@@ -47,9 +47,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => { public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => {
try { try {
const response = await this.axios.get<RadarrMovie>(`/movie/${id}`); const data = await this.get<RadarrMovie>(`/movie/${id}`);
return response.data; return data;
} catch (e) { } catch (e) {
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`); throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`);
} }
@@ -57,17 +57,15 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> { public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
try { try {
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', { const data = await this.get<RadarrMovie[]>('/movie/lookup', {
params: { term: `tmdb:${id}`,
term: `tmdb:${id}`,
},
}); });
if (!response.data[0]) { if (!data[0]) {
throw new Error('Movie not found'); throw new Error('Movie not found');
} }
return response.data[0]; return data[0];
} catch (e) { } catch (e) {
logger.error('Error retrieving movie by TMDB ID', { logger.error('Error retrieving movie by TMDB ID', {
label: 'Radarr API', label: 'Radarr API',
@@ -97,7 +95,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
// movie exists in Radarr but is neither downloaded nor monitored // movie exists in Radarr but is neither downloaded nor monitored
if (movie.id && !movie.monitored) { if (movie.id && !movie.monitored) {
const response = await this.axios.put<RadarrMovie>(`/movie`, { const data = await this.put<RadarrMovie>(`/movie`, {
...movie, ...movie,
title: options.title, title: options.title,
qualityProfileId: options.qualityProfileId, qualityProfileId: options.qualityProfileId,
@@ -114,25 +112,25 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
}, },
}); });
if (response.data.monitored) { if (data.monitored) {
logger.info( logger.info(
'Found existing title in Radarr and set it to monitored.', 'Found existing title in Radarr and set it to monitored.',
{ {
label: 'Radarr', label: 'Radarr',
movieId: response.data.id, movieId: data.id,
movieTitle: response.data.title, movieTitle: data.title,
} }
); );
logger.debug('Radarr update details', { logger.debug('Radarr update details', {
label: 'Radarr', label: 'Radarr',
movie: response.data, movie: data,
}); });
if (options.searchNow) { if (options.searchNow) {
this.searchMovie(response.data.id); this.searchMovie(data.id);
} }
return response.data; return data;
} else { } else {
logger.error('Failed to update existing movie in Radarr.', { logger.error('Failed to update existing movie in Radarr.', {
label: 'Radarr', label: 'Radarr',
@@ -150,7 +148,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
return movie; return movie;
} }
const response = await this.axios.post<RadarrMovie>(`/movie`, { const data = await this.post<RadarrMovie>(`/movie`, {
title: options.title, title: options.title,
qualityProfileId: options.qualityProfileId, qualityProfileId: options.qualityProfileId,
profileId: options.profileId, profileId: options.profileId,
@@ -166,11 +164,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
}, },
}); });
if (response.data.id) { if (data.id) {
logger.info('Radarr accepted request', { label: 'Radarr' }); logger.info('Radarr accepted request', { label: 'Radarr' });
logger.debug('Radarr add details', { logger.debug('Radarr add details', {
label: 'Radarr', label: 'Radarr',
movie: response.data, movie: data,
}); });
} else { } else {
logger.error('Failed to add movie to Radarr', { logger.error('Failed to add movie to Radarr', {
@@ -179,15 +177,22 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
}); });
throw new Error('Failed to add movie to Radarr'); throw new Error('Failed to add movie to Radarr');
} }
return response.data; return data;
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error( 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.', 'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
{ {
label: 'Radarr', label: 'Radarr',
errorMessage: e.message, errorMessage: e.message,
options, options,
response: e?.response?.data, response: errorData,
} }
); );
throw new Error('Failed to add movie to Radarr'); throw new Error('Failed to add movie to Radarr');
@@ -216,11 +221,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public removeMovie = async (movieId: number): Promise<void> => { public removeMovie = async (movieId: number): Promise<void> => {
try { try {
const { id, title } = await this.getMovieByTmdbId(movieId); const { id, title } = await this.getMovieByTmdbId(movieId);
await this.axios.delete(`/movie/${id}`, { await this.delete(`/movie/${id}`, {
params: { deleteFiles: 'true',
deleteFiles: true, addImportExclusion: 'false',
addImportExclusion: false,
},
}); });
logger.info(`[Radarr] Removed movie ${title}`); logger.info(`[Radarr] Removed movie ${title}`);
} catch (e) { } catch (e) {

View File

@@ -117,9 +117,9 @@ class SonarrAPI extends ServarrBase<{
public async getSeries(): Promise<SonarrSeries[]> { public async getSeries(): Promise<SonarrSeries[]> {
try { try {
const response = await this.axios.get<SonarrSeries[]>('/series'); const data = await this.get<SonarrSeries[]>('/series');
return response.data; return data;
} catch (e) { } catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`); throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
} }
@@ -127,9 +127,9 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesById(id: number): Promise<SonarrSeries> { public async getSeriesById(id: number): Promise<SonarrSeries> {
try { try {
const response = await this.axios.get<SonarrSeries>(`/series/${id}`); const data = await this.get<SonarrSeries>(`/series/${id}`);
return response.data; return data;
} catch (e) { } catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`); throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
} }
@@ -137,17 +137,15 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> { public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try { try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', { const data = await this.get<SonarrSeries[]>('/series/lookup', {
params: { term: title,
term: title,
},
}); });
if (!response.data[0]) { if (!data[0]) {
throw new Error('No series found'); throw new Error('No series found');
} }
return response.data; return data;
} catch (e) { } catch (e) {
logger.error('Error retrieving series by series title', { logger.error('Error retrieving series by series title', {
label: 'Sonarr API', label: 'Sonarr API',
@@ -160,17 +158,15 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> { public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
try { try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', { const data = await this.get<SonarrSeries[]>('/series/lookup', {
params: { term: `tvdb:${id}`,
term: `tvdb:${id}`,
},
}); });
if (!response.data[0]) { if (!data[0]) {
throw new Error('Series not found'); throw new Error('Series not found');
} }
return response.data[0]; return data[0];
} catch (e) { } catch (e) {
logger.error('Error retrieving series by tvdb ID', { logger.error('Error retrieving series by tvdb ID', {
label: 'Sonarr API', label: 'Sonarr API',
@@ -191,27 +187,27 @@ class SonarrAPI extends ServarrBase<{
series.tags = options.tags ?? series.tags; series.tags = options.tags ?? series.tags;
series.seasons = this.buildSeasonList(options.seasons, series.seasons); series.seasons = this.buildSeasonList(options.seasons, series.seasons);
const newSeriesResponse = await this.axios.put<SonarrSeries>( const newSeriesData = await this.put<SonarrSeries>(
'/series', '/series',
series series as any
); );
if (newSeriesResponse.data.id) { if (newSeriesData.id) {
logger.info('Updated existing series in Sonarr.', { logger.info('Updated existing series in Sonarr.', {
label: 'Sonarr', label: 'Sonarr',
seriesId: newSeriesResponse.data.id, seriesId: newSeriesData.id,
seriesTitle: newSeriesResponse.data.title, seriesTitle: newSeriesData.title,
}); });
logger.debug('Sonarr update details', { logger.debug('Sonarr update details', {
label: 'Sonarr', label: 'Sonarr',
movie: newSeriesResponse.data, movie: newSeriesData,
}); });
if (options.searchNow) { if (options.searchNow) {
this.searchSeries(newSeriesResponse.data.id); this.searchSeries(newSeriesData.id);
} }
return newSeriesResponse.data; return newSeriesData;
} else { } else {
logger.error('Failed to update series in Sonarr', { logger.error('Failed to update series in Sonarr', {
label: 'Sonarr', label: 'Sonarr',
@@ -221,38 +217,35 @@ class SonarrAPI extends ServarrBase<{
} }
} }
const createdSeriesResponse = await this.axios.post<SonarrSeries>( const createdSeriesData = await this.post<SonarrSeries>('/series', {
'/series', tvdbId: options.tvdbid,
{ title: options.title,
tvdbId: options.tvdbid, qualityProfileId: options.profileId,
title: options.title, languageProfileId: options.languageProfileId,
qualityProfileId: options.profileId, seasons: this.buildSeasonList(
languageProfileId: options.languageProfileId, options.seasons,
seasons: this.buildSeasonList( series.seasons.map((season) => ({
options.seasons, seasonNumber: season.seasonNumber,
series.seasons.map((season) => ({ // We force all seasons to false if its the first request
seasonNumber: season.seasonNumber, monitored: false,
// We force all seasons to false if its the first request }))
monitored: false, ),
})) tags: options.tags,
), seasonFolder: options.seasonFolder,
tags: options.tags, monitored: options.monitored,
seasonFolder: options.seasonFolder, rootFolderPath: options.rootFolderPath,
monitored: options.monitored, seriesType: options.seriesType,
rootFolderPath: options.rootFolderPath, addOptions: {
seriesType: options.seriesType, ignoreEpisodesWithFiles: true,
addOptions: { searchForMissingEpisodes: options.searchNow,
ignoreEpisodesWithFiles: true, },
searchForMissingEpisodes: options.searchNow, } as Partial<SonarrSeries>);
},
} as Partial<SonarrSeries>
);
if (createdSeriesResponse.data.id) { if (createdSeriesData.id) {
logger.info('Sonarr accepted request', { label: 'Sonarr' }); logger.info('Sonarr accepted request', { label: 'Sonarr' });
logger.debug('Sonarr add details', { logger.debug('Sonarr add details', {
label: 'Sonarr', label: 'Sonarr',
movie: createdSeriesResponse.data, movie: createdSeriesData,
}); });
} else { } else {
logger.error('Failed to add movie to Sonarr', { logger.error('Failed to add movie to Sonarr', {
@@ -262,13 +255,20 @@ class SonarrAPI extends ServarrBase<{
throw new Error('Failed to add series to Sonarr'); throw new Error('Failed to add series to Sonarr');
} }
return createdSeriesResponse.data; return createdSeriesData;
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Something went wrong while adding a series to Sonarr.', { logger.error('Something went wrong while adding a series to Sonarr.', {
label: 'Sonarr API', label: 'Sonarr API',
errorMessage: e.message, errorMessage: e.message,
options, options,
response: e?.response?.data, response: errorData,
}); });
throw new Error('Failed to add series'); throw new Error('Failed to add series');
} }
@@ -303,10 +303,10 @@ class SonarrAPI extends ServarrBase<{
}); });
try { try {
await this.runCommand('SeriesSearch', { seriesId }); await this.runCommand('MissingEpisodeSearch', { seriesId });
} catch (e) { } catch (e) {
logger.error( logger.error(
'Something went wrong while executing Sonarr series search.', 'Something went wrong while executing Sonarr missing episode search.',
{ {
label: 'Sonarr API', label: 'Sonarr API',
errorMessage: e.message, errorMessage: e.message,
@@ -340,14 +340,13 @@ class SonarrAPI extends ServarrBase<{
return newSeasons; return newSeasons;
} }
public removeSerie = async (serieId: number): Promise<void> => { public removeSerie = async (serieId: number): Promise<void> => {
try { try {
const { id, title } = await this.getSeriesByTvdbId(serieId); const { id, title } = await this.getSeriesByTvdbId(serieId);
await this.axios.delete(`/series/${id}`, { await this.delete(`/series/${id}`, {
params: { deleteFiles: 'true',
deleteFiles: true, addImportExclusion: 'false',
addImportExclusion: false,
},
}); });
logger.info(`[Radarr] Removed serie ${title}`); logger.info(`[Radarr] Removed serie ${title}`);
} catch (e) { } catch (e) {

View File

@@ -1,8 +1,7 @@
import ExternalAPI from '@server/api/externalapi';
import type { User } from '@server/entity/User'; import type { User } from '@server/entity/User';
import type { TautulliSettings } from '@server/lib/settings'; import type { TautulliSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import { uniqWith } from 'lodash'; import { uniqWith } from 'lodash';
export interface TautulliHistoryRecord { export interface TautulliHistoryRecord {
@@ -113,25 +112,25 @@ interface TautulliInfoResponse {
}; };
} }
class TautulliAPI { class TautulliAPI extends ExternalAPI {
private axios: AxiosInstance;
constructor(settings: TautulliSettings) { constructor(settings: TautulliSettings) {
this.axios = axios.create({ super(
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${ `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
settings.port settings.port
}${settings.urlBase ?? ''}`, }${settings.urlBase ?? ''}`,
params: { apikey: settings.apiKey }, {
}); apikey: settings.apiKey || '',
}
);
} }
public async getInfo(): Promise<TautulliInfo> { public async getInfo(): Promise<TautulliInfo> {
try { try {
return ( return (
await this.axios.get<TautulliInfoResponse>('/api/v2', { await this.get<TautulliInfoResponse>('/api/v2', {
params: { cmd: 'get_tautulli_info' }, cmd: 'get_tautulli_info',
}) })
).data.response.data; ).response.data;
} catch (e) { } catch (e) {
logger.error('Something went wrong fetching Tautulli server info', { logger.error('Something went wrong fetching Tautulli server info', {
label: 'Tautulli API', label: 'Tautulli API',
@@ -148,14 +147,12 @@ class TautulliAPI {
): Promise<TautulliWatchStats[]> { ): Promise<TautulliWatchStats[]> {
try { try {
return ( return (
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', { await this.get<TautulliWatchStatsResponse>('/api/v2', {
params: { cmd: 'get_item_watch_time_stats',
cmd: 'get_item_watch_time_stats', rating_key: ratingKey,
rating_key: ratingKey, grouping: '1',
grouping: 1,
},
}) })
).data.response.data; ).response.data;
} catch (e) { } catch (e) {
logger.error( logger.error(
'Something went wrong fetching media watch stats from Tautulli', 'Something went wrong fetching media watch stats from Tautulli',
@@ -176,14 +173,12 @@ class TautulliAPI {
): Promise<TautulliWatchUser[]> { ): Promise<TautulliWatchUser[]> {
try { try {
return ( return (
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', { await this.get<TautulliWatchUsersResponse>('/api/v2', {
params: { cmd: 'get_item_user_stats',
cmd: 'get_item_user_stats', rating_key: ratingKey,
rating_key: ratingKey, grouping: '1',
grouping: 1,
},
}) })
).data.response.data; ).response.data;
} catch (e) { } catch (e) {
logger.error( logger.error(
'Something went wrong fetching media watch users from Tautulli', 'Something went wrong fetching media watch users from Tautulli',
@@ -206,15 +201,13 @@ class TautulliAPI {
} }
return ( return (
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', { await this.get<TautulliWatchStatsResponse>('/api/v2', {
params: { cmd: 'get_user_watch_time_stats',
cmd: 'get_user_watch_time_stats', user_id: user.plexId.toString(),
user_id: user.plexId, query_days: '0',
query_days: 0, grouping: '1',
grouping: 1,
},
}) })
).data.response.data[0]; ).response.data[0];
} catch (e) { } catch (e) {
logger.error( logger.error(
'Something went wrong fetching user watch stats from Tautulli', 'Something went wrong fetching user watch stats from Tautulli',
@@ -245,19 +238,17 @@ class TautulliAPI {
while (results.length < 20) { while (results.length < 20) {
const tautulliData = ( const tautulliData = (
await this.axios.get<TautulliHistoryResponse>('/api/v2', { await this.get<TautulliHistoryResponse>('/api/v2', {
params: { cmd: 'get_history',
cmd: 'get_history', grouping: '1',
grouping: 1, order_column: 'date',
order_column: 'date', order_dir: 'desc',
order_dir: 'desc', user_id: user.plexId.toString(),
user_id: user.plexId, media_type: 'movie,episode',
media_type: 'movie,episode', length: take.toString(),
length: take, start: start.toString(),
start,
},
}) })
).data.response.data.data; ).response.data.data;
if (!tautulliData.length) { if (!tautulliData.length) {
return results; return results;

View File

@@ -95,6 +95,7 @@ interface DiscoverTvOptions {
sortBy?: SortOptions; sortBy?: SortOptions;
watchRegion?: string; watchRegion?: string;
watchProviders?: string; watchProviders?: string;
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
} }
class TheMovieDb extends ExternalAPI { class TheMovieDb extends ExternalAPI {
@@ -112,8 +113,8 @@ class TheMovieDb extends ExternalAPI {
{ {
nodeCache: cacheManager.getCache('tmdb').data, nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: { rateLimit: {
maxRequests: 20,
maxRPS: 50, maxRPS: 50,
id: 'tmdb',
}, },
} }
); );
@@ -129,7 +130,10 @@ class TheMovieDb extends ExternalAPI {
}: SearchOptions): Promise<TmdbSearchMultiResponse> => { }: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try { try {
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', { const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
params: { query, page, include_adult: includeAdult, language }, query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
}); });
return data; return data;
@@ -152,13 +156,11 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => { }: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
try { try {
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', { const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
params: { query,
query, page: page.toString(),
page, include_adult: includeAdult ? 'true' : 'false',
include_adult: includeAdult, language,
language, primary_release_year: year?.toString() || '',
primary_release_year: year,
},
}); });
return data; return data;
@@ -181,13 +183,11 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => { }: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
try { try {
const data = await this.get<TmdbSearchTvResponse>('/search/tv', { const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
params: { query,
query, page: page.toString(),
page, include_adult: includeAdult ? 'true' : 'false',
include_adult: includeAdult, language,
language, first_air_date_year: year?.toString() || '',
first_air_date_year: year,
},
}); });
return data; return data;
@@ -210,7 +210,7 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbPersonDetails> => { }): Promise<TmdbPersonDetails> => {
try { try {
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, { const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
params: { language }, language,
}); });
return data; return data;
@@ -230,7 +230,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbPersonCombinedCredits>( const data = await this.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`, `/person/${personId}/combined_credits`,
{ {
params: { language }, language,
} }
); );
@@ -253,11 +253,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbMovieDetails>( const data = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`, `/movie/${movieId}`,
{ {
params: { language,
language, append_to_response:
append_to_response: 'credits,external_ids,videos,keywords,release_dates,watch/providers',
'credits,external_ids,videos,keywords,release_dates,watch/providers',
},
}, },
43200 43200
); );
@@ -279,11 +277,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbTvDetails>( const data = await this.get<TmdbTvDetails>(
`/tv/${tvId}`, `/tv/${tvId}`,
{ {
params: { language,
language, append_to_response:
append_to_response: 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
},
}, },
43200 43200
); );
@@ -307,10 +303,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSeasonWithEpisodes>( const data = await this.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`, `/tv/${tvId}/season/${seasonNumber}`,
{ {
params: { language: language || '',
language, append_to_response: 'external_ids',
append_to_response: 'external_ids',
},
} }
); );
@@ -333,10 +327,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`, `/movie/${movieId}/recommendations`,
{ {
params: { page: page.toString(),
page, language,
language,
},
} }
); );
@@ -359,10 +351,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`, `/movie/${movieId}/similar`,
{ {
params: { page: page.toString(),
page, language,
language,
},
} }
); );
@@ -385,10 +375,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`, `/keyword/${keywordId}/movies`,
{ {
params: { page: page.toString(),
page, language,
language,
},
} }
); );
@@ -411,10 +399,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>( const data = await this.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`, `/tv/${tvId}/recommendations`,
{ {
params: { page: page.toString(),
page, language,
language,
},
} }
); );
@@ -437,10 +423,8 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbSearchTvResponse> { }): Promise<TmdbSearchTvResponse> {
try { try {
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, { const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
params: { page: page.toString(),
page, language,
language,
},
}); });
return data; return data;
@@ -481,40 +465,38 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0]; .split('T')[0];
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', { const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: { sort_by: sortBy,
sort_by: sortBy, page: page.toString(),
page, include_adult: includeAdult ? 'true' : 'false',
include_adult: includeAdult, language,
language, region: this.region || '',
region: this.region, with_original_language:
with_original_language: originalLanguage && originalLanguage !== 'all'
originalLanguage && originalLanguage !== 'all' ? originalLanguage
? originalLanguage : originalLanguage === 'all'
: originalLanguage === 'all' ? ''
? undefined : this.originalLanguage || '',
: this.originalLanguage, // Set our release date values, but check if one is set and not the other,
// Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set!
// so we can force a past date or a future date. TMDB Requires both values if one is set! 'primary_release_date.gte':
'primary_release_date.gte': !primaryReleaseDateGte && primaryReleaseDateLte
!primaryReleaseDateGte && primaryReleaseDateLte ? defaultPastDate
? defaultPastDate : primaryReleaseDateGte || '',
: primaryReleaseDateGte, 'primary_release_date.lte':
'primary_release_date.lte': !primaryReleaseDateLte && primaryReleaseDateGte
!primaryReleaseDateLte && primaryReleaseDateGte ? defaultFutureDate
? defaultFutureDate : primaryReleaseDateLte || '',
: primaryReleaseDateLte, with_genres: genre || '',
with_genres: genre, with_companies: studio || '',
with_companies: studio, with_keywords: keywords || '',
with_keywords: keywords, 'with_runtime.gte': withRuntimeGte || '',
'with_runtime.gte': withRuntimeGte, 'with_runtime.lte': withRuntimeLte || '',
'with_runtime.lte': withRuntimeLte, 'vote_average.gte': voteAverageGte || '',
'vote_average.gte': voteAverageGte, 'vote_average.lte': voteAverageLte || '',
'vote_average.lte': voteAverageLte, 'vote_count.gte': voteCountGte || '',
'vote_count.gte': voteCountGte, 'vote_count.lte': voteCountLte || '',
'vote_count.lte': voteCountLte, watch_region: watchRegion || '',
watch_region: watchRegion, with_watch_providers: watchProviders || '',
with_watch_providers: watchProviders,
},
}); });
return data; return data;
@@ -542,6 +524,7 @@ class TheMovieDb extends ExternalAPI {
voteCountLte, voteCountLte,
watchProviders, watchProviders,
watchRegion, watchRegion,
withStatus,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => { }: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try { try {
const defaultFutureDate = new Date( const defaultFutureDate = new Date(
@@ -555,40 +538,41 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0]; .split('T')[0];
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', { const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: { sort_by: sortBy,
sort_by: sortBy, page: page.toString(),
page, language,
language, region: this.region || '',
region: this.region, // Set our release date values, but check if one is set and not the other,
// Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set!
// so we can force a past date or a future date. TMDB Requires both values if one is set! 'first_air_date.gte':
'first_air_date.gte': !firstAirDateGte && firstAirDateLte
!firstAirDateGte && firstAirDateLte ? defaultPastDate
? defaultPastDate : firstAirDateGte || '',
: firstAirDateGte, 'first_air_date.lte':
'first_air_date.lte': !firstAirDateLte && firstAirDateGte
!firstAirDateLte && firstAirDateGte ? defaultFutureDate
? defaultFutureDate : firstAirDateLte || '',
: firstAirDateLte, with_original_language:
with_original_language: originalLanguage && originalLanguage !== 'all'
originalLanguage && originalLanguage !== 'all' ? originalLanguage
? originalLanguage : originalLanguage === 'all'
: originalLanguage === 'all' ? ''
? undefined : this.originalLanguage || '',
: this.originalLanguage, include_null_first_air_dates: includeEmptyReleaseDate
include_null_first_air_dates: includeEmptyReleaseDate, ? 'true'
with_genres: genre, : 'false',
with_networks: network, with_genres: genre || '',
with_keywords: keywords, with_networks: network?.toString() || '',
'with_runtime.gte': withRuntimeGte, with_keywords: keywords || '',
'with_runtime.lte': withRuntimeLte, 'with_runtime.gte': withRuntimeGte || '',
'vote_average.gte': voteAverageGte, 'with_runtime.lte': withRuntimeLte || '',
'vote_average.lte': voteAverageLte, 'vote_average.gte': voteAverageGte || '',
'vote_count.gte': voteCountGte, 'vote_average.lte': voteAverageLte || '',
'vote_count.lte': voteCountLte, 'vote_count.gte': voteCountGte || '',
with_watch_providers: watchProviders, 'vote_count.lte': voteCountLte || '',
watch_region: watchRegion, with_watch_providers: watchProviders || '',
}, watch_region: watchRegion || '',
with_status: withStatus || '',
}); });
return data; return data;
@@ -608,12 +592,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbUpcomingMoviesResponse>( const data = await this.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming', '/movie/upcoming',
{ {
params: { page: page.toString(),
page, language,
language, region: this.region || '',
region: this.region, originalLanguage: this.originalLanguage || '',
originalLanguage: this.originalLanguage,
},
} }
); );
@@ -636,11 +618,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMultiResponse>( const data = await this.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`, `/trending/all/${timeWindow}`,
{ {
params: { page: page.toString(),
page, language,
language, region: this.region || '',
region: this.region,
},
} }
); );
@@ -661,9 +641,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`, `/trending/movie/${timeWindow}`,
{ {
params: { page: page.toString(),
page,
},
} }
); );
@@ -684,9 +662,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>( const data = await this.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`, `/trending/tv/${timeWindow}`,
{ {
params: { page: page.toString(),
page,
},
} }
); );
@@ -715,10 +691,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbExternalIdResponse>( const data = await this.get<TmdbExternalIdResponse>(
`/find/${externalId}`, `/find/${externalId}`,
{ {
params: { external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', language,
language,
},
} }
); );
@@ -808,9 +782,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCollection>( const data = await this.get<TmdbCollection>(
`/collection/${collectionId}`, `/collection/${collectionId}`,
{ {
params: { language,
language,
},
} }
); );
@@ -883,9 +855,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>( const data = await this.get<TmdbGenresResult>(
'/genre/movie/list', '/genre/movie/list',
{ {
params: { language,
language,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -897,9 +867,7 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>( const englishData = await this.get<TmdbGenresResult>(
'/genre/movie/list', '/genre/movie/list',
{ {
params: { language: 'en',
language: 'en',
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -934,9 +902,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>( const data = await this.get<TmdbGenresResult>(
'/genre/tv/list', '/genre/tv/list',
{ {
params: { language,
language,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -948,9 +914,7 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>( const englishData = await this.get<TmdbGenresResult>(
'/genre/tv/list', '/genre/tv/list',
{ {
params: { language: 'en',
language: 'en',
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1005,10 +969,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbKeywordSearchResponse>( const data = await this.get<TmdbKeywordSearchResponse>(
'/search/keyword', '/search/keyword',
{ {
params: { query,
query, page: page.toString(),
page,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1030,10 +992,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCompanySearchResponse>( const data = await this.get<TmdbCompanySearchResponse>(
'/search/company', '/search/company',
{ {
params: { query,
query, page: page.toString(),
page,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1053,9 +1013,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>( const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions', '/watch/providers/regions',
{ {
params: { language: language ? this.originalLanguage || '' : '',
language: language ?? this.originalLanguage,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1079,10 +1037,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie', '/watch/providers/movie',
{ {
params: { language: language ? this.originalLanguage || '' : '',
language: language ?? this.originalLanguage, watch_region: watchRegion,
watch_region: watchRegion,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1106,10 +1062,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv', '/watch/providers/tv',
{ {
params: { language: language ? this.originalLanguage || '' : '',
language: language ?? this.originalLanguage, watch_region: watchRegion,
watch_region: watchRegion,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );

View File

@@ -2,6 +2,7 @@ export enum ApiErrorCode {
InvalidUrl = 'INVALID_URL', InvalidUrl = 'INVALID_URL',
InvalidCredentials = 'INVALID_CREDENTIALS', InvalidCredentials = 'INVALID_CREDENTIALS',
InvalidAuthToken = 'INVALID_AUTH_TOKEN', InvalidAuthToken = 'INVALID_AUTH_TOKEN',
InvalidEmail = 'INVALID_EMAIL',
NotAdmin = 'NOT_ADMIN', NotAdmin = 'NOT_ADMIN',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',

View File

@@ -4,3 +4,8 @@ export enum MediaServerType {
EMBY, EMBY,
NOT_CONFIGURED, NOT_CONFIGURED,
} }
export enum ServerType {
JELLYFIN = 'Jellyfin',
EMBY = 'Emby',
}

View File

@@ -2,4 +2,5 @@ export enum UserType {
PLEX = 1, PLEX = 1,
LOCAL = 2, LOCAL = 2,
JELLYFIN = 3, JELLYFIN = 3,
EMBY = 4,
} }

View File

@@ -211,9 +211,10 @@ class Media {
} }
} else { } else {
const pageName = const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details'; getSettings().main.mediaServerType == MediaServerType.EMBY
? 'item'
: 'details';
const { serverId, externalHostname } = getSettings().jellyfin; const { serverId, externalHostname } = getSettings().jellyfin;
const jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname

View File

@@ -32,10 +32,17 @@ import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session'; import type { Store } from 'express-session';
import session from 'express-session'; import session from 'express-session';
import next from 'next'; import next from 'next';
import dns from 'node:dns';
import net from 'node:net';
import path from 'path'; import path from 'path';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs'; import YAML from 'yamljs';
if (process.env.forceIpv4First === 'true') {
dns.setDefaultResultOrder('ipv4first');
net.setDefaultAutoSelectFamily(false);
}
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Overseerr version ${getAppVersion()}`); logger.info(`Starting Overseerr version ${getAppVersion()}`);
@@ -56,7 +63,7 @@ app
} }
// Load Settings // Load Settings
const settings = getSettings().load(); const settings = await getSettings().load();
restartFlag.initializeSettings(settings.main); restartFlag.initializeSettings(settings.main);
// Migrate library types // Migrate library types

View File

@@ -8,3 +8,16 @@ interface PageInfo {
export interface PaginatedResponse { export interface PaginatedResponse {
pageInfo: PageInfo; pageInfo: PageInfo;
} }
/**
* Get the keys of an object that are not functions
*/
type NonFunctionPropertyNames<T> = {
// eslint-disable-next-line @typescript-eslint/ban-types
[K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
/**
* Get the properties of an object that are not functions
*/
export type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

View File

@@ -1,9 +1,9 @@
import type { MediaType } from '@server/constants/media'; import type { MediaType } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { PaginatedResponse } from './common'; import type { NonFunctionProperties, PaginatedResponse } from './common';
export interface RequestResultsResponse extends PaginatedResponse { export interface RequestResultsResponse extends PaginatedResponse {
results: MediaRequest[]; results: NonFunctionProperties<MediaRequest>[];
} }
export type MediaRequestBody = { export type MediaRequestBody = {
@@ -14,6 +14,7 @@ export type MediaRequestBody = {
is4k?: boolean; is4k?: boolean;
serverId?: number; serverId?: number;
profileId?: number; profileId?: number;
profileName?: string;
rootFolder?: string; rootFolder?: string;
languageProfileId?: number; languageProfileId?: number;
userId?: number; userId?: number;

View File

@@ -63,12 +63,7 @@ class AvailabilitySync {
) { ) {
admin = await userRepository.findOne({ admin = await userRepository.findOne({
where: { id: 1 }, where: { id: 1 },
select: [ select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
} }
@@ -86,7 +81,7 @@ class AvailabilitySync {
if (admin) { if (admin) {
this.jellyfinClient = new JellyfinAPI( this.jellyfinClient = new JellyfinAPI(
getHostname(), getHostname(),
admin.jellyfinAuthToken, settings.jellyfin.apiKey,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

@@ -20,6 +20,7 @@ export interface DownloadingItem {
timeLeft: string; timeLeft: string;
estimatedCompletionTime: Date; estimatedCompletionTime: Date;
title: string; title: string;
downloadId: string;
episode?: EpisodeNumberResult; episode?: EpisodeNumberResult;
} }
@@ -95,6 +96,7 @@ class DownloadTracker {
status: item.status, status: item.status,
timeLeft: item.timeleft, timeLeft: item.timeleft,
title: item.title, title: item.title,
downloadId: item.downloadId,
})); }));
if (queueItems.length > 0) { if (queueItems.length > 0) {
@@ -172,6 +174,7 @@ class DownloadTracker {
timeLeft: item.timeleft, timeLeft: item.timeleft,
title: item.title, title: item.title,
episode: item.episode, episode: item.episode,
downloadId: item.downloadId,
})); }));
if (queueItems.length > 0) { if (queueItems.length > 0) {

View File

@@ -1,6 +1,6 @@
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios'; import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit'; import rateLimit from '@server/utils/rateLimit';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { promises } from 'fs'; import { promises } from 'fs';
import path, { join } from 'path'; import path, { join } from 'path';
@@ -98,26 +98,29 @@ class ImageProxy {
return files.length; return files.length;
} }
private axios; private fetch: typeof fetch;
private cacheVersion; private cacheVersion;
private key; private key;
private baseUrl;
constructor( constructor(
key: string, key: string,
baseUrl: string, baseUrl: string,
options: { options: {
cacheVersion?: number; cacheVersion?: number;
rateLimitOptions?: rateLimitOptions; rateLimitOptions?: RateLimitOptions;
} = {} } = {}
) { ) {
this.cacheVersion = options.cacheVersion ?? 1; this.cacheVersion = options.cacheVersion ?? 1;
this.baseUrl = baseUrl;
this.key = key; this.key = key;
this.axios = axios.create({
baseURL: baseUrl,
});
if (options.rateLimitOptions) { if (options.rateLimitOptions) {
this.axios = rateLimit(this.axios, options.rateLimitOptions); this.fetch = rateLimit(fetch, {
...options.rateLimitOptions,
});
} else {
this.fetch = fetch;
} }
} }
@@ -182,17 +185,20 @@ class ImageProxy {
): Promise<ImageResponse | null> { ): Promise<ImageResponse | null> {
try { try {
const directory = join(this.getCacheDirectory(), cacheKey); const directory = join(this.getCacheDirectory(), cacheKey);
const response = await this.axios.get(path, { const href =
responseType: 'arraybuffer', this.baseUrl +
}); (this.baseUrl.endsWith('/') ? '' : '/') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const buffer = Buffer.from(response.data, 'binary');
const extension = path.split('.').pop() ?? ''; const extension = path.split('.').pop() ?? '';
const maxAge = Number( const maxAge = Number(
(response.headers['cache-control'] ?? '0').split('=')[1] (response.headers.get('cache-control') ?? '0').split('=')[1]
); );
const expireAt = Date.now() + maxAge * 1000; const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.etag ?? '').replace(/"/g, ''); const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
await this.writeToCacheDir( await this.writeToCacheDir(
directory, directory,

View File

@@ -4,7 +4,6 @@ import { User } from '@server/entity/User';
import type { NotificationAgentDiscord } from '@server/lib/settings'; import type { NotificationAgentDiscord } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -292,23 +291,39 @@ class DiscordAgent
} }
} }
await axios.post(settings.options.webhookUrl, { const response = await fetch(settings.options.webhookUrl, {
username: settings.options.botUsername method: 'POST',
? settings.options.botUsername headers: {
: getSettings().main.applicationTitle, 'Content-Type': 'application/json',
avatar_url: settings.options.botAvatarUrl, },
embeds: [this.buildEmbed(type, payload)], body: JSON.stringify({
content: userMentions.join(' '), username: settings.options.botUsername
} as DiscordWebhookPayload); ? settings.options.botUsername
: getSettings().main.applicationTitle,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content: userMentions.join(' '),
} as DiscordWebhookPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Discord notification', { logger.error('Error sending Discord notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;

View File

@@ -2,7 +2,6 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentGotify } from '@server/lib/settings'; import type { NotificationAgentGotify } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent'; import { BaseAgent } from './agent';
@@ -133,16 +132,32 @@ class GotifyAgent
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`; const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
const notificationPayload = this.getNotificationPayload(type, payload); const notificationPayload = this.getNotificationPayload(type, payload);
await axios.post(endpoint, notificationPayload); const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(notificationPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Gotify notification', { logger.error('Error sending Gotify notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;

View File

@@ -3,7 +3,6 @@ import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentLunaSea } from '@server/lib/settings'; import type { NotificationAgentLunaSea } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent'; import { BaseAgent } from './agent';
@@ -101,28 +100,39 @@ class LunaSeaAgent
}); });
try { try {
await axios.post( const response = await fetch(settings.options.webhookUrl, {
settings.options.webhookUrl, method: 'POST',
this.buildPayload(type, payload), headers: settings.options.profileName
settings.options.profileName
? { ? {
headers: { 'Content-Type': 'application/json',
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
} }
: undefined : {
); 'Content-Type': 'application/json',
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
body: JSON.stringify(this.buildPayload(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending LunaSea notification', { logger.error('Error sending LunaSea notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;

View File

@@ -5,7 +5,6 @@ import { User } from '@server/entity/User';
import type { NotificationAgentPushbullet } from '@server/lib/settings'; import type { NotificationAgentPushbullet } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -123,22 +122,34 @@ class PushbulletAgent
}); });
try { try {
await axios.post( const response = await fetch(endpoint, {
endpoint, method: 'POST',
{ ...notificationPayload, channel_tag: settings.options.channelTag }, headers: {
{ 'Content-Type': 'application/json',
headers: { 'Access-Token': settings.options.accessToken,
'Access-Token': settings.options.accessToken, },
}, body: JSON.stringify({
} ...notificationPayload,
); channel_tag: settings.options.channelTag,
}),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', { logger.error('Error sending Pushbullet notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;
@@ -163,19 +174,32 @@ class PushbulletAgent
}); });
try { try {
await axios.post(endpoint, notificationPayload, { const response = await fetch(endpoint, {
method: 'POST',
headers: { headers: {
'Content-Type': 'application/json',
'Access-Token': payload.notifyUser.settings.pushbulletAccessToken, 'Access-Token': payload.notifyUser.settings.pushbulletAccessToken,
}, },
body: JSON.stringify(notificationPayload),
}); });
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', { logger.error('Error sending Pushbullet notification', {
label: 'Notifications', label: 'Notifications',
recipient: payload.notifyUser.displayName, recipient: payload.notifyUser.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;
@@ -211,19 +235,32 @@ class PushbulletAgent
}); });
try { try {
await axios.post(endpoint, notificationPayload, { const response = await fetch(endpoint, {
method: 'POST',
headers: { headers: {
'Content-Type': 'application/json',
'Access-Token': user.settings.pushbulletAccessToken, 'Access-Token': user.settings.pushbulletAccessToken,
}, },
body: JSON.stringify(notificationPayload),
}); });
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', { logger.error('Error sending Pushbullet notification', {
label: 'Notifications', label: 'Notifications',
recipient: user.displayName, recipient: user.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;

View File

@@ -5,7 +5,6 @@ import { User } from '@server/entity/User';
import type { NotificationAgentPushover } from '@server/lib/settings'; import type { NotificationAgentPushover } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -52,12 +51,15 @@ class PushoverAgent
imageUrl: string imageUrl: string
): Promise<Partial<PushoverImagePayload>> { ): Promise<Partial<PushoverImagePayload>> {
try { try {
const response = await axios.get(imageUrl, { const response = await fetch(imageUrl);
responseType: 'arraybuffer', if (!response.ok) {
}); throw new Error(response.statusText, { cause: response });
const base64 = Buffer.from(response.data, 'binary').toString('base64'); }
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
const contentType = ( const contentType = (
response.headers['Content-Type'] || response.headers['content-type'] response.headers.get('Content-Type') ||
response.headers.get('content-type')
)?.toString(); )?.toString();
return { return {
@@ -65,10 +67,17 @@ class PushoverAgent
attachment_type: contentType, attachment_type: contentType,
}; };
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error getting image payload', { logger.error('Error getting image payload', {
label: 'Notifications', label: 'Notifications',
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return {}; return {};
} }
@@ -201,19 +210,35 @@ class PushoverAgent
}); });
try { try {
await axios.post(endpoint, { const response = await fetch(endpoint, {
...notificationPayload, method: 'POST',
token: settings.options.accessToken, headers: {
user: settings.options.userToken, 'Content-Type': 'application/json',
sound: settings.options.sound, },
} as PushoverPayload); body: JSON.stringify({
...notificationPayload,
token: settings.options.accessToken,
user: settings.options.userToken,
sound: settings.options.sound,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', { logger.error('Error sending Pushover notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;
@@ -241,20 +266,36 @@ class PushoverAgent
}); });
try { try {
await axios.post(endpoint, { const response = await fetch(endpoint, {
...notificationPayload, method: 'POST',
token: payload.notifyUser.settings.pushoverApplicationToken, headers: {
user: payload.notifyUser.settings.pushoverUserKey, 'Content-Type': 'application/json',
sound: payload.notifyUser.settings.pushoverSound, },
} as PushoverPayload); body: JSON.stringify({
...notificationPayload,
token: payload.notifyUser.settings.pushoverApplicationToken,
user: payload.notifyUser.settings.pushoverUserKey,
sound: payload.notifyUser.settings.pushoverSound,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', { logger.error('Error sending Pushover notification', {
label: 'Notifications', label: 'Notifications',
recipient: payload.notifyUser.displayName, recipient: payload.notifyUser.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;
@@ -291,19 +332,35 @@ class PushoverAgent
}); });
try { try {
await axios.post(endpoint, { const response = await fetch(endpoint, {
...notificationPayload, method: 'POST',
token: user.settings.pushoverApplicationToken, headers: {
user: user.settings.pushoverUserKey, 'Content-Type': 'application/json',
} as PushoverPayload); },
body: JSON.stringify({
...notificationPayload,
token: user.settings.pushoverApplicationToken,
user: user.settings.pushoverUserKey,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', { logger.error('Error sending Pushover notification', {
label: 'Notifications', label: 'Notifications',
recipient: user.displayName, recipient: user.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;

View File

@@ -2,7 +2,6 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentSlack } from '@server/lib/settings'; import type { NotificationAgentSlack } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent'; import { BaseAgent } from './agent';
@@ -238,19 +237,32 @@ class SlackAgent
subject: payload.subject, subject: payload.subject,
}); });
try { try {
await axios.post( const response = await fetch(settings.options.webhookUrl, {
settings.options.webhookUrl, method: 'POST',
this.buildEmbed(type, payload) headers: {
); 'Content-Type': 'application/json',
},
body: JSON.stringify(this.buildEmbed(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Slack notification', { logger.error('Error sending Slack notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;

View File

@@ -5,7 +5,6 @@ import { User } from '@server/entity/User';
import type { NotificationAgentTelegram } from '@server/lib/settings'; import type { NotificationAgentTelegram } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
@@ -175,18 +174,34 @@ class TelegramAgent
}); });
try { try {
await axios.post(endpoint, { const response = await fetch(endpoint, {
...notificationPayload, method: 'POST',
chat_id: settings.options.chatId, headers: {
disable_notification: !!settings.options.sendSilently, 'Content-Type': 'application/json',
} as TelegramMessagePayload | TelegramPhotoPayload); },
body: JSON.stringify({
...notificationPayload,
chat_id: settings.options.chatId,
disable_notification: !!settings.options.sendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', { logger.error('Error sending Telegram notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;
@@ -210,20 +225,36 @@ class TelegramAgent
}); });
try { try {
await axios.post(endpoint, { const response = await fetch(endpoint, {
...notificationPayload, method: 'POST',
chat_id: payload.notifyUser.settings.telegramChatId, headers: {
disable_notification: 'Content-Type': 'application/json',
!!payload.notifyUser.settings.telegramSendSilently, },
} as TelegramMessagePayload | TelegramPhotoPayload); body: JSON.stringify({
...notificationPayload,
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
!!payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', { logger.error('Error sending Telegram notification', {
label: 'Notifications', label: 'Notifications',
recipient: payload.notifyUser.displayName, recipient: payload.notifyUser.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;
@@ -257,19 +288,35 @@ class TelegramAgent
}); });
try { try {
await axios.post(endpoint, { const response = await fetch(endpoint, {
...notificationPayload, method: 'POST',
chat_id: user.settings.telegramChatId, headers: {
disable_notification: !!user.settings?.telegramSendSilently, 'Content-Type': 'application/json',
} as TelegramMessagePayload | TelegramPhotoPayload); },
body: JSON.stringify({
...notificationPayload,
chat_id: user.settings.telegramChatId,
disable_notification: !!user.settings?.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', { logger.error('Error sending Telegram notification', {
label: 'Notifications', label: 'Notifications',
recipient: user.displayName, recipient: user.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;

View File

@@ -3,7 +3,6 @@ import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentWebhook } from '@server/lib/settings'; import type { NotificationAgentWebhook } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import axios from 'axios';
import { get } from 'lodash'; import { get } from 'lodash';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
@@ -178,26 +177,35 @@ class WebhookAgent
}); });
try { try {
await axios.post( const response = await fetch(settings.options.webhookUrl, {
settings.options.webhookUrl, method: 'POST',
this.buildPayload(type, payload), headers: {
settings.options.authHeader 'Content-Type': 'application/json',
? { ...(settings.options.authHeader
headers: { ? { Authorization: settings.options.authHeader }
Authorization: settings.options.authHeader, : {}),
}, },
} body: JSON.stringify(this.buildPayload(type, payload)),
: undefined });
); if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true; return true;
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending webhook notification', { logger.error('Error sending webhook notification', {
label: 'Notifications', label: 'Notifications',
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,
response: e.response?.data, response: errorData,
}); });
return false; return false;

View File

@@ -567,7 +567,10 @@ class JellyfinScanner {
public async run(): Promise<void> { public async run(): Promise<void> {
const settings = getSettings(); const settings = getSettings();
if (settings.main.mediaServerType != MediaServerType.JELLYFIN) { if (
settings.main.mediaServerType != MediaServerType.JELLYFIN &&
settings.main.mediaServerType != MediaServerType.EMBY
) {
return; return;
} }
@@ -582,12 +585,7 @@ class JellyfinScanner {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOne({ const admin = await userRepository.findOne({
where: { id: 1 }, where: { id: 1 },
select: [ select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
@@ -595,11 +593,9 @@ class JellyfinScanner {
return this.log('No admin configured. Jellyfin sync skipped.', 'warn'); return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
} }
const hostname = getHostname();
this.jfClient = new JellyfinAPI( this.jfClient = new JellyfinAPI(
hostname, getHostname(),
admin.jellyfinAuthToken, settings.jellyfin.apiKey,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

@@ -47,6 +47,7 @@ export interface JellyfinSettings {
jellyfinForgotPasswordUrl?: string; jellyfinForgotPasswordUrl?: string;
libraries: Library[]; libraries: Library[];
serverId: string; serverId: string;
apiKey: string;
} }
export interface TautulliSettings { export interface TautulliSettings {
hostname?: string; hostname?: string;
@@ -342,6 +343,7 @@ class Settings {
jellyfinForgotPasswordUrl: '', jellyfinForgotPasswordUrl: '',
libraries: [], libraries: [],
serverId: '', serverId: '',
apiKey: '',
}, },
tautulli: {}, tautulli: {},
radarr: [], radarr: [],
@@ -609,7 +611,11 @@ class Settings {
} }
private generateApiKey(): string { private generateApiKey(): string {
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64'); if (process.env.API_KEY) {
return process.env.API_KEY;
} else {
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
}
} }
private generateVapidKeys(force = false): void { private generateVapidKeys(force = false): void {
@@ -629,7 +635,7 @@ class Settings {
* @param overrideSettings If passed in, will override all existing settings with these * @param overrideSettings If passed in, will override all existing settings with these
* values * values
*/ */
public load(overrideSettings?: AllSettings): Settings { public async load(overrideSettings?: AllSettings): Promise<Settings> {
if (overrideSettings) { if (overrideSettings) {
this.data = overrideSettings; this.data = overrideSettings;
return this; return this;
@@ -642,10 +648,16 @@ class Settings {
if (data) { if (data) {
const parsedJson = JSON.parse(data); const parsedJson = JSON.parse(data);
this.data = runMigrations(parsedJson); this.data = await runMigrations(parsedJson);
this.data = merge(this.data, parsedJson); this.data = merge(this.data, parsedJson);
if (process.env.API_KEY) {
if (this.main.apiKey != process.env.API_KEY) {
this.main.apiKey = process.env.API_KEY;
}
}
this.save(); this.save();
} }
return this; return this;

View File

@@ -0,0 +1,36 @@
import JellyfinAPI from '@server/api/jellyfin';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { AllSettings } from '@server/lib/settings';
import { getHostname } from '@server/utils/getHostname';
const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
const mediaServerType = settings.main.mediaServerType;
if (
!settings.jellyfin.apiKey &&
(mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY)
) {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
where: { id: 1 },
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
if (!admin) {
return settings;
}
const jellyfinClient = new JellyfinAPI(
getHostname(settings.jellyfin),
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
settings.jellyfin.apiKey = apiKey;
}
return settings;
};
export default migrateApiTokens;

View File

@@ -0,0 +1,16 @@
import { MediaServerType } from '@server/constants/server';
import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => {
const oldMediaServerType = settings.main.mediaServerType;
if (
oldMediaServerType === MediaServerType.JELLYFIN &&
process.env.JELLYFIN_TYPE === 'emby'
) {
settings.main.mediaServerType = MediaServerType.EMBY;
}
return settings;
};
export default migrateHostname;

View File

@@ -1,10 +1,13 @@
import type { AllSettings } from '@server/lib/settings'; import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations'); const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = (settings: AllSettings): AllSettings => { export const runMigrations = async (
settings: AllSettings
): Promise<AllSettings> => {
const migrations = fs const migrations = fs
.readdirSync(migrationsDir) .readdirSync(migrationsDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts')) .filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
@@ -13,8 +16,15 @@ export const runMigrations = (settings: AllSettings): AllSettings => {
let migrated = settings; let migrated = settings;
for (const migration of migrations) { try {
migrated = migration(migrated); for (const migration of migrations) {
migrated = await migration(migrated);
}
} catch (e) {
logger.error(
`Something went wrong while running settings migrations: ${e.message}`,
{ label: 'Settings Migrator' }
);
} }
return migrated; return migrated;

View File

@@ -85,6 +85,7 @@ export interface MovieDetails {
mediaUrl?: string; mediaUrl?: string;
watchProviders?: WatchProviders[]; watchProviders?: WatchProviders[];
keywords: Keyword[]; keywords: Keyword[];
onUserWatchlist?: boolean;
} }
export const mapProductionCompany = ( export const mapProductionCompany = (
@@ -101,7 +102,8 @@ export const mapProductionCompany = (
export const mapMovieDetails = ( export const mapMovieDetails = (
movie: TmdbMovieDetails, movie: TmdbMovieDetails,
media?: Media media?: Media,
userWatchlist?: boolean
): MovieDetails => ({ ): MovieDetails => ({
id: movie.id, id: movie.id,
adult: movie.adult, adult: movie.adult,
@@ -148,4 +150,5 @@ export const mapMovieDetails = (
id: keyword.id, id: keyword.id,
name: keyword.name, name: keyword.name,
})), })),
onUserWatchlist: userWatchlist,
}); });

View File

@@ -111,6 +111,7 @@ export interface TvDetails {
keywords: Keyword[]; keywords: Keyword[];
mediaInfo?: Media; mediaInfo?: Media;
watchProviders?: WatchProviders[]; watchProviders?: WatchProviders[];
onUserWatchlist?: boolean;
} }
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
@@ -161,7 +162,8 @@ export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({
export const mapTvDetails = ( export const mapTvDetails = (
show: TmdbTvDetails, show: TmdbTvDetails,
media?: Media media?: Media,
userWatchlist?: boolean
): TvDetails => ({ ): TvDetails => ({
createdBy: show.created_by, createdBy: show.created_by,
episodeRunTime: show.episode_run_time, episodeRunTime: show.episode_run_time,
@@ -223,4 +225,5 @@ export const mapTvDetails = (
})), })),
mediaInfo: media, mediaInfo: media,
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}), watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
onUserWatchlist: userWatchlist,
}); });

View File

@@ -1,7 +1,7 @@
import JellyfinAPI from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import { ApiErrorCode } from '@server/constants/error'; import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType, ServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user'; import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
@@ -227,15 +227,20 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
urlBase?: string; urlBase?: string;
useSsl?: boolean; useSsl?: boolean;
email?: string; email?: string;
serverType?: number;
}; };
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured //Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
if ( if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN && settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED settings.main.mediaServerType !== MediaServerType.EMBY &&
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
settings.jellyfin.ip !== ''
) { ) {
return res.status(500).json({ error: 'Jellyfin login is disabled' }); return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.username) { }
if (!body.username) {
return res.status(500).json({ error: 'You must provide an username' }); return res.status(500).json({ error: 'You must provide an username' });
} else if (settings.jellyfin.ip !== '' && body.hostname) { } else if (settings.jellyfin.ip !== '' && body.hostname) {
return res return res
@@ -273,7 +278,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
} }
// First we need to attempt to log the user in to jellyfin // First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId); const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
const jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
@@ -317,20 +323,55 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
); );
// User doesn't exist, and there are no users in the database, we'll create the user // User doesn't exist, and there are no users in the database, we'll create the user
// with admin permission // with admin permissions
settings.main.mediaServerType = MediaServerType.JELLYFIN; switch (body.serverType) {
user = new User({ case MediaServerType.EMBY:
email: body.email, settings.main.mediaServerType = MediaServerType.EMBY;
jellyfinUsername: account.User.Name, user = new User({
jellyfinUserId: account.User.Id, email: body.email || account.User.Name,
jellyfinDeviceId: deviceId, jellyfinUsername: account.User.Name,
jellyfinAuthToken: account.AccessToken, jellyfinUserId: account.User.Id,
permissions: Permission.ADMIN, jellyfinDeviceId: deviceId,
avatar: account.User.PrimaryImageTag jellyfinAuthToken: account.AccessToken,
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` permissions: Permission.ADMIN,
: gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }), avatar: account.User.PrimaryImageTag
userType: UserType.JELLYFIN, ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
}); : gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
userType: UserType.EMBY,
});
break;
case MediaServerType.JELLYFIN:
settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN,
});
break;
default:
throw new Error('select_server_type');
}
// Create an API key on Jellyfin from this admin user
const jellyfinClient = new JellyfinAPI(
hostname,
account.AccessToken,
deviceId
);
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
const serverName = await jellyfinserver.getServerName(); const serverName = await jellyfinserver.getServerName();
@@ -340,6 +381,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.port = body.port ?? 8096; settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.apiKey = apiKey;
settings.save(); settings.save();
startJobs(); startJobs();
@@ -350,12 +392,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
logger.info( logger.info(
`Found matching ${ `Found matching ${
settings.main.mediaServerType === MediaServerType.JELLYFIN settings.main.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin' ? ServerType.JELLYFIN
: 'Emby' : ServerType.EMBY
} user; updating user with ${ } user; updating user with ${
settings.main.mediaServerType === MediaServerType.JELLYFIN settings.main.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin' ? ServerType.JELLYFIN
: 'Emby' : ServerType.EMBY
}`, }`,
{ {
label: 'API', label: 'API',
@@ -363,15 +405,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
} }
); );
// Let's check if their authtoken is up to date
if (user.jellyfinAuthToken !== account.AccessToken) {
user.jellyfinAuthToken = account.AccessToken;
}
// Update the users avatar with their jellyfin profile pic (incase it changed) // Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) { if (account.User.PrimaryImageTag) {
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
} else { } else {
user.avatar = gravatarUrl(user.email, { user.avatar = gravatarUrl(user.email || account.User.Name, {
default: 'mm', default: 'mm',
size: 200, size: 200,
}); });
@@ -382,12 +420,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
user.username = ''; user.username = '';
} }
// TODO: If JELLYFIN_TYPE is set to 'emby' then set mediaServerType to EMBY
// if (process.env.JELLYFIN_TYPE === 'emby') {
// settings.main.mediaServerType = MediaServerType.EMBY;
// settings.save();
// }
await userRepository.save(user); await userRepository.save(user);
} else if (!settings.main.newPlexLogin) { } else if (!settings.main.newPlexLogin) {
logger.warn( logger.warn(
@@ -413,21 +445,22 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
} }
); );
if (!body.email) {
throw new Error('add_email');
}
user = new User({ user = new User({
email: body.email, email: body.email,
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id, jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId, jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: account.User.PrimaryImageTag avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email, { default: 'mm', size: 200 }), : gravatarUrl(body.email || account.User.Name, {
userType: UserType.JELLYFIN, default: 'mm',
size: 200,
}),
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
}); });
//initialize Jellyfin/Emby users with local login //initialize Jellyfin/Emby users with local login
const passedExplicitPassword = body.password && body.password.length > 0; const passedExplicitPassword = body.password && body.password.length > 0;
@@ -730,6 +763,7 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => {
}); });
} }
user.recoveryLinkExpirationDate = null; user.recoveryLinkExpirationDate = null;
await user.setPassword(req.body.password);
userRepository.save(user); userRepository.save(user);
logger.info('Successfully reset password', { logger.info('Successfully reset password', {
label: 'API', label: 'API',

View File

@@ -71,6 +71,7 @@ const QueryFilterOptions = z.object({
network: z.coerce.string().optional(), network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(), watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(), watchRegion: z.coerce.string().optional(),
status: z.coerce.string().optional(),
}); });
export type FilterOptions = z.infer<typeof QueryFilterOptions>; export type FilterOptions = z.infer<typeof QueryFilterOptions>;
@@ -385,6 +386,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
voteCountLte: query.voteCountLte, voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders, watchProviders: query.watchProviders,
watchRegion: query.watchRegion, watchRegion: query.watchRegion,
withStatus: query.status,
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

View File

@@ -5,7 +5,6 @@ import { Router } from 'express';
const router = Router(); const router = Router();
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', { const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: { rateLimitOptions: {
maxRequests: 20,
maxRPS: 50, maxRPS: 50,
}, },
}); });

View File

@@ -3,7 +3,9 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes';
import { type RatingResponse } from '@server/api/ratings'; import { type RatingResponse } from '@server/api/ratings';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { Watchlist } from '@server/entity/Watchlist';
import logger from '@server/logger'; import logger from '@server/logger';
import { mapMovieDetails } from '@server/models/Movie'; import { mapMovieDetails } from '@server/models/Movie';
import { mapMovieResult } from '@server/models/Search'; import { mapMovieResult } from '@server/models/Search';
@@ -22,7 +24,24 @@ movieRoutes.get('/:id', async (req, res, next) => {
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
return res.status(200).json(mapMovieDetails(tmdbMovie, media)); const onUserWatchlist = await getRepository(Watchlist).exist({
where: {
tmdbId: Number(req.params.id),
requestedBy: {
id: req.user?.id,
},
},
});
const data = mapMovieDetails(tmdbMovie, media, onUserWatchlist);
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
const tvEnglish = await tmdb.getMovie({ movieId: Number(req.params.id) });
data.overview = tvEnglish.overview;
}
return res.status(200).json(data);
} catch (e) { } catch (e) {
logger.debug('Something went wrong retrieving movie', { logger.debug('Something went wrong retrieving movie', {
label: 'API', label: 'API',

View File

@@ -1,3 +1,5 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { import {
MediaRequestStatus, MediaRequestStatus,
MediaStatus, MediaStatus,
@@ -19,6 +21,7 @@ import type {
RequestResultsResponse, RequestResultsResponse,
} from '@server/interfaces/api/requestInterfaces'; } from '@server/interfaces/api/requestInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express'; import { Router } from 'express';
@@ -143,6 +146,62 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
.skip(skip) .skip(skip)
.getManyAndCount(); .getManyAndCount();
const settings = getSettings();
// get all quality profiles for every configured sonarr server
const sonarrServers = await Promise.all(
settings.sonarr.map(async (sonarrSetting) => {
const sonarr = new SonarrAPI({
apiKey: sonarrSetting.apiKey,
url: SonarrAPI.buildUrl(sonarrSetting, '/api/v3'),
});
return {
id: sonarrSetting.id,
profiles: await sonarr.getProfiles(),
};
})
);
// get all quality profiles for every configured radarr server
const radarrServers = await Promise.all(
settings.radarr.map(async (radarrSetting) => {
const radarr = new RadarrAPI({
apiKey: radarrSetting.apiKey,
url: RadarrAPI.buildUrl(radarrSetting, '/api/v3'),
});
return {
id: radarrSetting.id,
profiles: await radarr.getProfiles(),
};
})
);
// add profile names to the media requests, with undefined if not found
const requestsWithProfileNames = requests.map((r) => {
switch (r.type) {
case MediaType.MOVIE: {
const profileName = radarrServers
.find((serverr) => serverr.id === r.serverId)
?.profiles.find((profile) => profile.id === r.profileId)?.name;
return {
...r,
profileName,
};
}
case MediaType.TV: {
return {
...r,
profileName: sonarrServers
.find((serverr) => serverr.id === r.serverId)
?.profiles.find((profile) => profile.id === r.profileId)?.name,
};
}
}
});
return res.status(200).json({ return res.status(200).json({
pageInfo: { pageInfo: {
pages: Math.ceil(requestCount / pageSize), pages: Math.ceil(requestCount / pageSize),
@@ -150,7 +209,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
results: requestCount, results: requestCount,
page: Math.ceil(skip / pageSize) + 1, page: Math.ceil(skip / pageSize) + 1,
}, },
results: requests, results: requestsWithProfileNames,
}); });
} catch (e) { } catch (e) {
next({ status: 500, message: e.message }); next({ status: 500, message: e.message });

View File

@@ -262,7 +262,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
try { try {
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
where: { id: 1 }, where: { id: 1 },
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'], select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
@@ -270,7 +270,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings), getHostname(tempJellyfinSettings),
admin.jellyfinAuthToken ?? '', tempJellyfinSettings.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -318,13 +318,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
if (req.query.sync) { if (req.query.sync) {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 }, where: { id: 1 },
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(), getHostname(),
admin.jellyfinAuthToken ?? '', settings.jellyfin.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -376,7 +376,8 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
}); });
settingsRoutes.get('/jellyfin/users', async (req, res) => { settingsRoutes.get('/jellyfin/users', async (req, res) => {
const { externalHostname } = getSettings().jellyfin; const settings = getSettings();
const { externalHostname } = settings.jellyfin;
const jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
@@ -384,12 +385,13 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 }, where: { id: 1 },
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
admin.jellyfinAuthToken ?? '', getHostname(),
settings.jellyfin.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );

View File

@@ -1,7 +1,9 @@
import RottenTomatoes from '@server/api/rating/rottentomatoes'; import RottenTomatoes from '@server/api/rating/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { Watchlist } from '@server/entity/Watchlist';
import logger from '@server/logger'; import logger from '@server/logger';
import { mapTvResult } from '@server/models/Search'; import { mapTvResult } from '@server/models/Search';
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
@@ -19,7 +21,24 @@ tvRoutes.get('/:id', async (req, res, next) => {
const media = await Media.getMedia(tv.id, MediaType.TV); const media = await Media.getMedia(tv.id, MediaType.TV);
return res.status(200).json(mapTvDetails(tv, media)); const onUserWatchlist = await getRepository(Watchlist).exist({
where: {
tmdbId: Number(req.params.id),
requestedBy: {
id: req.user?.id,
},
},
});
const data = mapTvDetails(tv, media, onUserWatchlist);
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
data.overview = tvEnglish.overview;
}
return res.status(200).json(data);
} catch (e) { } catch (e) {
logger.debug('Something went wrong retrieving series', { logger.debug('Something went wrong retrieving series', {
label: 'API', label: 'API',

View File

@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli'; import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user'; import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
@@ -41,7 +42,19 @@ router.get('/', async (req, res, next) => {
break; break;
case 'displayname': case 'displayname':
query = query.orderBy( query = query.orderBy(
"(CASE WHEN (user.username IS NULL OR user.username = '') THEN (CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN user.email ELSE LOWER(user.plexUsername) END) ELSE LOWER(user.username) END)", `CASE WHEN (user.username IS NULL OR user.username = '') THEN (
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
user.email
ELSE
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.username)
END`,
'ASC' 'ASC'
); );
break; break;
@@ -90,12 +103,13 @@ router.post(
const settings = getSettings(); const settings = getSettings();
const body = req.body; const body = req.body;
const email = body.email || body.username;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const existingUser = await userRepository const existingUser = await userRepository
.createQueryBuilder('user') .createQueryBuilder('user')
.where('user.email = :email', { .where('user.email = :email', {
email: body.email.toLowerCase(), email: email.toLowerCase(),
}) })
.getOne(); .getOne();
@@ -108,7 +122,7 @@ router.post(
} }
const passedExplicitPassword = body.password && body.password.length > 0; const passedExplicitPassword = body.password && body.password.length > 0;
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 }); const avatar = gravatarUrl(email, { default: 'mm', size: 200 });
if ( if (
!passedExplicitPassword && !passedExplicitPassword &&
@@ -118,9 +132,9 @@ router.post(
} }
const user = new User({ const user = new User({
email,
avatar: body.avatar ?? avatar, avatar: body.avatar ?? avatar,
username: body.username, username: body.username,
email: body.email,
password: body.password, password: body.password,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
plexToken: '', plexToken: '',
@@ -488,16 +502,14 @@ router.post(
// taken from auth.ts // taken from auth.ts
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
where: { id: 1 }, where: { id: 1 },
select: [ select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
'id',
'jellyfinAuthToken',
'jellyfinDeviceId',
'jellyfinUserId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const hostname = getHostname();
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
admin.jellyfinAuthToken ?? '', hostname,
settings.jellyfin.apiKey,
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
@@ -505,7 +517,6 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers(); //const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = []; const createdUsers: User[] = [];
const { externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
const hostname = getHostname();
const jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
@@ -540,7 +551,10 @@ router.post(
default: 'mm', default: 'mm',
size: 200, size: 200,
}), }),
userType: UserType.JELLYFIN, userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
}); });
await userRepository.save(newUser); await userRepository.save(newUser);

View File

@@ -1,3 +1,4 @@
import { ApiErrorCode } from '@server/constants/error';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import { UserSettings } from '@server/entity/UserSettings'; import { UserSettings } from '@server/entity/UserSettings';
@@ -9,6 +10,7 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error';
import { Router } from 'express'; import { Router } from 'express';
import { canMakePermissionsChange } from '.'; import { canMakePermissionsChange } from '.';
@@ -98,7 +100,17 @@ userSettingsRoutes.post<
} }
user.username = req.body.username; user.username = req.body.username;
user.email = req.body.email ?? user.email; const oldEmail = user.email;
if (user.jellyfinUsername) {
user.email = req.body.email || user.jellyfinUsername || user.email;
}
const existingUser = await userRepository.findOne({
where: { email: user.email },
});
if (oldEmail !== user.email && existingUser) {
throw new ApiError(400, ApiErrorCode.InvalidEmail);
}
// Update quota values only if the user has the correct permissions // Update quota values only if the user has the correct permissions
if ( if (
@@ -143,7 +155,14 @@ userSettingsRoutes.post<
email: savedUser.email, email: savedUser.email,
}); });
} catch (e) { } catch (e) {
next({ status: 500, message: e.message }); if (e.errorCode) {
return next({
status: e.statusCode,
message: e.errorCode,
});
} else {
return next({ status: 500, message: e.message });
}
} }
}); });

68
server/utils/rateLimit.ts Normal file
View File

@@ -0,0 +1,68 @@
export type RateLimitOptions = {
maxRPS: number;
id?: string;
};
type RateLimiteState<T extends (...args: Parameters<T>) => Promise<U>, U> = {
queue: {
args: Parameters<T>;
resolve: (value: U) => void;
reject: (reason?: unknown) => void;
}[];
lastTimestamps: number[];
timeout: ReturnType<typeof setTimeout>;
};
const rateLimitById: Record<string, unknown> = {};
/**
* Add a rate limit to a function so it doesn't exceed a maximum number of requests per second. Function calls exceeding the rate will be delayed.
* @param fn The function to rate limit
* @param options.maxRPS Maximum number of Requests Per Second
* @param options.id An ID to share between rate limits, so it uses the same request queue.
* @returns The function with a rate limit
*/
export default function rateLimit<
T extends (...args: Parameters<T>) => Promise<U>,
U
>(fn: T, options: RateLimitOptions): (...args: Parameters<T>) => Promise<U> {
const state: RateLimiteState<T, U> = (rateLimitById[
options.id || ''
] as RateLimiteState<T, U>) || { queue: [], lastTimestamps: [] };
if (options.id) {
rateLimitById[options.id] = state;
}
const processQueue = () => {
// remove old timestamps
state.lastTimestamps = state.lastTimestamps.filter(
(timestamp) => Date.now() - timestamp < 1000
);
if (state.lastTimestamps.length < options.maxRPS) {
// process requests if RPS not exceeded
const item = state.queue.shift();
if (!item) return;
state.lastTimestamps.push(Date.now());
const { args, resolve, reject } = item;
fn(...args)
.then(resolve)
.catch(reject);
processQueue();
} else {
// rerun once the oldest item in queue is older than 1s
if (state.timeout) clearTimeout(state.timeout);
state.timeout = setTimeout(
processQueue,
1000 - (Date.now() - state.lastTimestamps[0])
);
}
};
return (...args: Parameters<T>): Promise<U> => {
return new Promise<U>((resolve, reject) => {
state.queue.push({ args, resolve, reject });
processQueue();
});
};
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="svg2"
viewBox="0 0 712.60077 712.5481"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs4" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<rect
style="opacity:0;fill:#ffffff;stroke-width:4.12543"
id="rect249"
width="712.60077"
height="712.5481"
x="-0.00071160076"
y="2.0223413e-11" />
<rect
style="fill:#ffffff"
id="rect289"
width="230.18982"
height="229.82355"
x="241.20476"
y="241.36227" />
<g
id="layer1"
transform="matrix(0.70249853,0,0,0.70249853,88.770447,96.84571)">
<path
id="path3427"
d="m 327.06546,642.18589 c -45.39663,-45.86009 -82.73776,-83.3683 -82.98029,-83.3516 -0.24253,0.0167 -7.23324,6.65975 -15.53493,14.7623 l -15.09396,14.73193 -40.13624,-40.38805 C 151.24511,525.72706 108.73555,482.86504 78.854363,452.69158 l -54.329437,-54.86086 83.720394,-82.90796 83.72039,-82.90797 -15.19316,-15.20441 -15.19315,-15.20443 95.18008,-94.29313 c 52.34904,-51.86121 95.35849,-94.293118 95.57653,-94.293118 0.21805,0 37.39519,37.357576 82.61589,83.016832 45.22068,45.659256 82.53772,83.131956 82.92673,83.272666 0.38901,0.14071 7.46336,-6.49498 15.72077,-14.746 l 15.01348,-15.00184 7.14591,7.19902 c 73.95232,74.50189 181.50599,183.56427 181.36678,183.9109 -0.10065,0.25064 -37.54056,37.44106 -83.19981,82.64536 -45.65926,45.2043 -83.10802,82.41429 -83.21946,82.68884 -0.11145,0.27456 6.50478,7.34753 14.70272,15.71771 l 14.90534,15.21851 -15.3888,15.28883 c -21.09609,20.95904 -162.95155,161.27018 -169.79551,167.947 l -5.52526,5.39033 z m 89.8523,-204.1566 c 64.84836,-37.53366 117.81919,-68.54793 117.71294,-68.92058 -0.15927,-0.55862 -233.55022,-136.2489 -236.27084,-137.3646 -0.68441,-0.28068 -0.85761,27.45642 -0.85761,137.33982 0,99.83563 0.20749,137.62237 0.75471,137.43996 0.41509,-0.13837 53.81245,-30.96093 118.6608,-68.4946 z"
style="fill:#52b54b;fill-opacity:1;stroke:none" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,46 +1,131 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
<svg y="0px" width="100%" viewBox="0 0 617 188" enable-background="new 0 0 617 188" xml:space="preserve">
version="1.1" <path fill="#52B54B" opacity="1.000000" stroke="none" d="
id="svg2" M89.583336,1.000000
viewBox="0 0 712.60077 712.5481" C90.189529,1.685005 90.166168,2.574803 90.599510,3.025271
xmlns="http://www.w3.org/2000/svg" C103.718315,16.662701 116.882103,30.256845 129.948212,43.764053
xmlns:svg="http://www.w3.org/2000/svg" C130.577850,43.523941 130.916519,43.491173 131.111343,43.306595
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" C138.657471,36.157455 138.655273,36.156090 146.005478,43.505203
xmlns:cc="http://creativecommons.org/ns#" C159.538589,57.036308 173.016449,70.623535 186.654617,84.047913
xmlns:dc="http://purl.org/dc/elements/1.1/"> C189.264145,86.616562 189.414017,88.253456 186.716782,90.895164
<defs C174.709808,102.655037 162.893280,114.609337 151.008514,126.493958
id="defs4" /> C146.073502,131.428925 146.076691,131.427155 151.017944,136.523712
<metadata C151.698944,137.226120 152.340485,137.966812 153.259171,138.973434
id="metadata7"> C151.947098,140.380035 150.766312,141.712204 149.516266,142.975861
<rdf:RDF> C134.544815,158.110641 119.563087,173.235260 104.792023,188.681274
<cc:Work C103.611107,189.000000 102.222221,189.000000 100.624634,188.681274
rdf:about=""> C86.361732,174.796494 72.307518,161.230438 57.702755,147.132965
<dc:format>image/svg+xml</dc:format> C56.157101,149.136856 54.135899,151.757263 51.994804,154.533112
<dc:type C35.932781,138.457108 20.569420,123.048477 5.141897,107.704361
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> C3.997114,106.565773 2.391420,105.890610 1.000000,105.000000
</cc:Work> C1.000000,103.611107 1.000000,102.222221 1.318741,100.624641
</rdf:RDF> C15.203506,86.361694 28.769531,72.307434 42.867004,57.702602
</metadata> C40.863205,56.156994 38.242813,54.135792 35.425343,51.962570
<rect C51.518696,35.908516 66.939468,20.557360 82.295547,5.141749
style="opacity:0;fill:#ffffff;stroke-width:4.12543" C83.434830,3.998048 84.109390,2.391417 85.000000,0.999999
id="rect249" C86.388893,1.000000 87.777779,1.000000 89.583336,1.000000
width="712.60077" M73.196465,79.500702
height="712.5481" C73.196465,96.254150 73.196465,113.007599 73.196465,130.872055
x="-0.00071160076" C94.273178,118.764557 114.417175,107.192863 135.221664,95.241745
y="2.0223413e-11" /> C114.247169,83.251732 94.091187,71.729622 73.196594,59.785294
<rect C73.196594,66.631348 73.196594,72.566254 73.196465,79.500702
style="fill:#ffffff" z" />
id="rect289" <path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
width="230.18982" M618.000000,60.571537
height="229.82355" C617.004395,62.042580 615.613281,62.912964 615.073181,64.153824
x="241.20476" C608.143372,80.073746 601.328613,96.043816 594.498169,112.006920
y="241.36227" /> C586.973572,129.592300 579.343018,147.133865 571.999390,164.794601
<g C568.632385,172.892075 568.893372,173.002594 560.133972,172.999832
id="layer1" C555.470825,172.998367 550.807617,172.994385 546.144592,172.969360
transform="matrix(0.70249853,0,0,0.70249853,88.770447,96.84571)"> C545.841980,172.967712 545.540466,172.775543 544.836609,172.534256
<path C548.592896,163.531219 551.714905,154.222061 556.286133,145.689255
id="path3427" C559.733765,139.253830 559.138794,134.062668 556.454224,127.695969
d="m 327.06546,642.18589 c -45.39663,-45.86009 -82.73776,-83.3683 -82.98029,-83.3516 -0.24253,0.0167 -7.23324,6.65975 -15.53493,14.7623 l -15.09396,14.73193 -40.13624,-40.38805 C 151.24511,525.72706 108.73555,482.86504 78.854363,452.69158 l -54.329437,-54.86086 83.720394,-82.90796 83.72039,-82.90797 -15.19316,-15.20441 -15.19315,-15.20443 95.18008,-94.29313 c 52.34904,-51.86121 95.35849,-94.293118 95.57653,-94.293118 0.21805,0 37.39519,37.357576 82.61589,83.016832 45.22068,45.659256 82.53772,83.131956 82.92673,83.272666 0.38901,0.14071 7.46336,-6.49498 15.72077,-14.746 l 15.01348,-15.00184 7.14591,7.19902 c 73.95232,74.50189 181.50599,183.56427 181.36678,183.9109 -0.10065,0.25064 -37.54056,37.44106 -83.19981,82.64536 -45.65926,45.2043 -83.10802,82.41429 -83.21946,82.68884 -0.11145,0.27456 6.50478,7.34753 14.70272,15.71771 l 14.90534,15.21851 -15.3888,15.28883 c -21.09609,20.95904 -162.95155,161.27018 -169.79551,167.947 l -5.52526,5.39033 z m 89.8523,-204.1566 c 64.84836,-37.53366 117.81919,-68.54793 117.71294,-68.92058 -0.15927,-0.55862 -233.55022,-136.2489 -236.27084,-137.3646 -0.68441,-0.28068 -0.85761,27.45642 -0.85761,137.33982 0,99.83563 0.20749,137.62237 0.75471,137.43996 0.41509,-0.13837 53.81245,-30.96093 118.6608,-68.4946 z" C546.360352,103.757523 536.803345,79.592712 526.837830,55.000847
style="fill:#52b54b;fill-opacity:1;stroke:none" /> C534.817078,55.000847 542.437622,54.725182 550.003540,55.244331
</g> C551.436218,55.342628 553.169678,58.412052 553.885010,60.423309
C558.720520,74.018005 563.307556,87.700912 568.003784,101.345413
C569.107483,104.551987 570.321045,107.720764 571.976196,112.255157
C573.889587,107.365631 575.415283,103.375916 577.007935,99.413109
C582.693298,85.266724 588.344238,71.105591 594.218018,57.037624
C594.650513,56.001743 596.734497,55.132927 598.079773,55.089733
C604.401855,54.886726 610.734131,54.999401 617.531372,54.999699
C618.000000,56.714359 618.000000,58.428715 618.000000,60.571537
z" />
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
M430.000122,99.002235
C430.000122,112.477097 430.000122,125.452438 430.000122,138.713440
C423.048126,138.713440 416.308685,138.713440 408.999878,138.713440
C408.999878,129.350739 409.120758,119.916939 408.962219,110.487823
C408.832153,102.753624 409.088898,94.909142 407.866791,87.324188
C406.440887,78.474220 401.302399,74.201607 394.304291,74.000290
C387.617249,73.807938 380.317963,79.297188 378.047363,86.438652
C377.420715,88.409592 377.055725,90.550858 377.044647,92.616508
C376.962494,107.913475 377.000122,123.211082 377.000122,138.753479
C369.630646,138.753479 362.559692,138.753479 354.999878,138.753479
C354.999878,123.256836 355.044769,107.816956 354.977661,92.377571
C354.951050,86.251518 352.748199,80.799278 347.911346,77.066116
C339.239685,70.373154 327.811401,74.635170 324.084412,84.471092
C322.793915,87.876816 322.147491,91.713402 322.090881,95.366882
C321.868958,109.685005 322.000122,124.008591 322.000122,138.665009
C314.823853,138.665009 307.760773,138.665009 300.346558,138.665009
C300.346558,111.006645 300.346558,83.281189 300.346558,55.001301
C306.163818,55.001301 312.104645,54.855133 318.024780,55.139343
C319.060455,55.189068 320.450378,56.891682 320.882477,58.112110
C321.380768,59.519447 320.998291,61.238617 320.998291,64.136040
C328.715179,54.407440 338.407898,52.804527 348.408875,54.206123
C356.403381,55.326527 361.770447,57.638248 366.682190,66.544373
C372.325470,62.972542 377.601440,58.269657 383.771973,56.014080
C396.273407,51.444298 408.602570,53.673611 419.067657,61.818150
C426.629364,67.703125 429.037811,76.770744 429.932556,86.011482
C430.332214,90.138710 430.000122,94.336792 430.000122,99.002235
z" />
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
M462.000427,35.006332
C462.000427,44.815434 462.000427,54.126144 462.000427,64.132019
C468.844696,58.319965 476.100769,54.654530 484.669739,53.656227
C496.686127,52.256294 507.565582,54.979622 516.927185,62.503853
C534.236755,76.416115 535.360107,106.231667 523.651062,123.341644
C516.745056,133.433182 506.539673,139.485458 493.555267,140.111023
C483.836304,140.579254 474.670624,139.889420 466.610413,133.799713
C465.039795,132.613068 463.390686,131.530289 461.957214,130.525391
C461.633789,132.375305 461.105469,135.397171 460.522095,138.733841
C454.446686,138.733841 448.017822,138.733841 441.292542,138.733841
C441.292542,99.722672 441.292542,60.652122 441.292542,21.290209
C447.943787,21.290209 454.684204,21.290209 462.000427,21.290209
C462.000427,25.636984 462.000427,30.072460 462.000427,35.006332
M480.890228,119.974937
C485.426086,119.681152 490.365997,120.444260 494.421356,118.893707
C506.182587,114.396866 510.858643,104.919495 509.036591,92.234833
C507.422546,80.997993 496.539307,71.772278 483.551605,73.864754
C469.724976,76.092384 464.376770,85.538391 463.152863,96.752327
C462.120667,106.209480 469.961761,116.189537 480.890228,119.974937
z" />
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
M234.797928,54.654831
C244.856339,52.605957 254.504562,52.040043 264.239868,54.923946
C279.600891,59.474377 286.402191,68.163963 289.768585,81.937614
C291.530579,89.146889 290.954620,96.927589 291.469940,105.005005
C269.550385,105.005005 248.375092,105.005005 227.094437,105.005005
C229.577957,116.288628 239.741562,120.764336 248.594757,121.034813
C256.790771,121.285217 264.390472,119.882645 271.081848,114.731178
C271.774902,114.197632 273.962708,114.659111 274.786041,115.402222
C278.726318,118.958458 282.435333,122.770882 286.509888,126.770363
C281.309174,132.968170 274.787445,135.946014 267.542938,138.175064
C253.746231,142.420120 240.209259,142.317459 227.237503,135.935410
C212.712891,128.789368 205.730453,116.523628 204.973831,100.473404
C204.537735,91.222557 205.503754,82.283119 210.008469,74.017265
C215.396210,64.131088 223.372589,57.511646 234.797928,54.654831
M266.971497,78.708908
C259.384399,70.789909 249.920425,70.480316 240.489548,73.410858
C234.405487,75.301414 229.437546,79.631561 227.800247,86.722244
C242.152313,86.722244 256.002747,86.722244 270.947815,86.722244
C269.410950,83.870155 268.228943,81.676651 266.971497,78.708908
z" />
<path fill="#FCFEFC" opacity="1.000000" stroke="none" d="
M73.196533,79.000931
C73.196594,72.566254 73.196594,66.631348 73.196594,59.785294
C94.091187,71.729622 114.247169,83.251732 135.221664,95.241745
C114.417175,107.192863 94.273178,118.764557 73.196465,130.872055
C73.196465,113.007599 73.196465,96.254150 73.196533,79.000931
z" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -1 +1,85 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320.03 103.61"><defs><style>.cls-1{fill:#fff}.cls-2{fill:url(#a)}.cls-3{fill:#e5a00d}</style><radialGradient id="a" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#f9be03"/><stop offset=".51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><polygon points="320.03 -.09 289.96 -.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79" class="cls-1"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-2"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-3"/><path d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z" class="cls-1"/><path d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z" class="cls-1"/><path d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z" class="cls-1"/></svg> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="plex-logo"
x="0px"
y="0px"
viewBox="0 0 1000 460.89727"
xml:space="preserve"
sodipodi:docname="plex-logo.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
id="metadata25"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs23">
</defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#111111"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview21"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="0.27956081"
inkscape:cx="783.06912"
inkscape:cy="-132.85701"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="plex-logo" />
<style
type="text/css"
id="style2">
.st0{fill:#FFFFFF;}
.st1{fill:#EBAF00;}
</style>
<path
class="st0"
d="m 164.18919,82.43243 c -39.86487,0 -65.540543,11.48648 -87.162163,38.51351 V 91.21621 H 0 v 366.21621 c 0,0 1.3513514,0.67567 5.4054053,1.35135 5.4054057,1.35135 33.7837827,7.43243 54.7297287,-10.13514 18.243244,-15.54054 22.297295,-33.78378 22.297295,-54.05405 v -52.7027 c 22.297301,23.64864 47.297301,33.78378 82.432431,33.78378 75.67567,0 133.78378,-61.48648 133.78378,-143.24323 0,-88.51352 -56.08108,-150 -134.45945,-150 z m -14.86487,223.64864 c -42.56756,0 -76.351351,-35.13513 -76.351351,-77.7027 0,-41.89189 39.864871,-75.67567 76.351351,-75.67567 43.24324,0 76.35135,33.1081 76.35135,76.35135 0,43.24324 -33.78378,77.02702 -76.35135,77.02702 z"
id="path4"
inkscape:connector-curvature="0"
style="fill:#ffffff;stroke-width:6.75675678" /><path
class="st0"
d="m 408.1081,223.64864 c 0,31.75676 3.37838,70.27027 34.45946,112.16216 0.67567,0.67567 2.02702,2.7027 2.02702,2.7027 -12.83783,21.62162 -28.37837,36.48648 -49.32432,36.48648 -16.21622,0 -32.43243,-8.78378 -45.94595,-23.64864 -14.18918,-16.21622 -20.94594,-37.16216 -20.94594,-59.45946 V 0 h 79.05405 z"
id="path6"
inkscape:connector-curvature="0"
style="fill:#ffffff;stroke-width:6.75675678" /><polygon
class="st1"
points="117.9,33.9 104.1,13.5 118.3,13.5 132,33.9 118.3,54.2 104.1,54.2 "
id="polygon8"
style="fill:#ebaf00"
transform="scale(6.7567568)" /><polygon
class="st0"
points="135.7,31.6 148,13.5 133.8,13.5 128.7,21 "
id="polygon10"
style="fill:#ffffff"
transform="scale(6.7567568)" /><path
class="st0"
d="m 869.59458,316.2162 c 0,0 16.2162,22.2973 16.2162,22.2973 15.54058,24.32432 35.8108,36.48648 59.45949,36.48648 25,-0.67567 42.56752,-22.29729 49.3243,-30.4054 0,0 -12.16218,-10.81081 -27.7027,-29.05405 -20.94598,-24.32432 -48.64868,-68.91892 -49.3243,-70.94594 z"
id="path12"
inkscape:connector-curvature="0"
style="fill:#ffffff;stroke-width:6.75675678" /><path
style="fill:#ffffff;stroke-width:6.75675678"
inkscape:connector-curvature="0"
id="path16"
d="m 632.43242,287.16215 c -16.21622,14.86486 -27.02703,22.97297 -49.32432,22.97297 -39.86487,0 -62.83784,-28.37837 -66.21622,-59.45945 h 211.4865 c 1.35131,-4.05406 2.027,-9.45946 2.027,-18.24324 0,-85.81082 -62.83783,-150 -145.27026,-150 -78.37837,0 -142.56756,65.54054 -142.56756,147.29729 0,81.08108 64.18919,145.27026 144.59459,145.27026 56.08108,0 104.72973,-31.75675 131.08105,-87.83783 z M 585.8108,147.29729 c 35.13513,0 61.48648,22.97297 67.56756,53.37838 H 519.59458 c 6.75676,-31.75676 31.75676,-53.37838 66.21622,-53.37838 z"
class="st0" />
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -14,7 +14,6 @@ import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { Keyword, ProductionCompany } from '@server/models/common'; import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -77,11 +76,9 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const keywords = await Promise.all( const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => { slider.data.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>( const res = await fetch(`/api/v1/keyword/${keywordId}`);
`/api/v1/keyword/${keywordId}` const keyword: Keyword = await res.json();
); return keyword;
return keyword.data;
}) })
); );
@@ -98,15 +95,13 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
return; return;
} }
const response = await axios.get<TmdbGenre[]>( const res = await fetch(
`/api/v1/genres/${ `/api/v1/genres/${
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv' slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
}` }`
); );
const genres: TmdbGenre[] = await res.json();
const genre = response.data.find( const genre = genres.find((genre) => genre.id === Number(slider.data));
(genre) => genre.id === Number(slider.data)
);
setDefaultDataValue([ setDefaultDataValue([
{ {
@@ -121,11 +116,8 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
return; return;
} }
const response = await axios.get<ProductionCompany>( const res = await fetch(`/api/v1/studio/${slider.data}`);
`/api/v1/studio/${slider.data}` const studio: ProductionCompany = await res.json();
);
const studio = response.data;
setDefaultDataValue([ setDefaultDataValue([
{ {
@@ -168,16 +160,17 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
); );
const loadKeywordOptions = async (inputValue: string) => { const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>( const res = await fetch(
'/api/v1/search/keyword', `/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`,
{ {
params: { headers: {
query: encodeURIExtraParams(inputValue), 'Content-Type': 'application/json',
}, },
} }
); );
const results: TmdbKeywordSearchResponse = await res.json();
return results.data.results.map((result) => ({ return results.results.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));
@@ -188,38 +181,37 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
return []; return [];
} }
const results = await axios.get<TmdbCompanySearchResponse>( const res = await fetch(
'/api/v1/search/company', `/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`,
{ {
params: { headers: {
query: encodeURIExtraParams(inputValue), 'Content-Type': 'application/json',
}, },
} }
); );
const results: TmdbCompanySearchResponse = await res.json();
return results.data.results.map((result) => ({ return results.results.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));
}; };
const loadMovieGenreOptions = async () => { const loadMovieGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>( const res = await fetch('/api/v1/discover/genreslider/movie');
'/api/v1/discover/genreslider/movie' const results: GenreSliderItem[] = await res.json();
);
return results.data.map((result) => ({ return results.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));
}; };
const loadTvGenreOptions = async () => { const loadTvGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>( const res = await fetch('/api/v1/discover/genreslider/tv');
'/api/v1/discover/genreslider/tv' const results: GenreSliderItem[] = await res.json();
);
return results.data.map((result) => ({ return results.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));
@@ -314,17 +306,31 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
onSubmit={async (values, { resetForm }) => { onSubmit={async (values, { resetForm }) => {
try { try {
if (slider) { if (slider) {
await axios.put(`/api/v1/settings/discover/${slider.id}`, { const res = await fetch(`/api/v1/settings/discover/${slider.id}`, {
type: Number(values.sliderType), method: 'PUT',
title: values.title, headers: {
data: values.data, 'Content-Type': 'application/json',
},
body: JSON.stringify({
type: Number(values.sliderType),
title: values.title,
data: values.data,
}),
}); });
if (!res.ok) throw new Error();
} else { } else {
await axios.post('/api/v1/settings/discover/add', { const res = await fetch('/api/v1/settings/discover/add', {
type: Number(values.sliderType), method: 'POST',
title: values.title, headers: {
data: values.data, 'Content-Type': 'application/json',
},
body: JSON.stringify({
type: Number(values.sliderType),
title: values.title,
data: values.data,
}),
}); });
if (!res.ok) throw new Error();
} }
addToast( addToast(

View File

@@ -48,11 +48,11 @@ const DiscoverTvNetwork = () => {
<div className="mt-1 mb-5"> <div className="mt-1 mb-5">
<Header> <Header>
{firstResultData?.network.logoPath ? ( {firstResultData?.network.logoPath ? (
<div className="mb-6 flex justify-center"> <div className="relative mb-6 flex h-24 justify-center sm:h-32">
<Image <Image
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`} src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
alt={firstResultData.network.name} alt={firstResultData.network.name}
className="max-h-24 sm:max-h-32" className="object-contain"
fill fill
/> />
</div> </div>

View File

@@ -20,7 +20,6 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover'; import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-aria'; import { useDrag, useDrop } from 'react-aria';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -78,7 +77,10 @@ const DiscoverSliderEdit = ({
const deleteSlider = async () => { const deleteSlider = async () => {
try { try {
await axios.delete(`/api/v1/settings/discover/${slider.id}`); const res = await fetch(`/api/v1/settings/discover/${slider.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.deletesuccess), { addToast(intl.formatMessage(messages.deletesuccess), {
appearance: 'success', appearance: 'success',
autoDismiss: true, autoDismiss: true,

View File

@@ -48,11 +48,11 @@ const DiscoverMovieStudio = () => {
<div className="mt-1 mb-5"> <div className="mt-1 mb-5">
<Header> <Header>
{firstResultData?.studio.logoPath ? ( {firstResultData?.studio.logoPath ? (
<div className="mb-6 flex justify-center"> <div className="relative mb-6 flex h-24 justify-center sm:h-32">
<Image <Image
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`} src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
alt={firstResultData.studio.name} alt={firstResultData.studio.name}
className="max-h-24 sm:max-h-32" className="object-contain"
fill fill
/> />
</div> </div>

View File

@@ -8,6 +8,7 @@ import {
CompanySelector, CompanySelector,
GenreSelector, GenreSelector,
KeywordSelector, KeywordSelector,
StatusSelector,
WatchProviderSelector, WatchProviderSelector,
} from '@app/components/Selector'; } from '@app/components/Selector';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
@@ -40,6 +41,7 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
runtime: 'Runtime', runtime: 'Runtime',
streamingservices: 'Streaming Services', streamingservices: 'Streaming Services',
voteCount: 'Number of votes between {minValue} and {maxValue}', voteCount: 'Number of votes between {minValue} and {maxValue}',
status: 'Status',
}); });
type FilterSlideoverProps = { type FilterSlideoverProps = {
@@ -150,6 +152,23 @@ const FilterSlideover = ({
updateQueryParams('genre', value?.map((v) => v.value).join(',')); updateQueryParams('genre', value?.map((v) => v.value).join(','));
}} }}
/> />
{type === 'tv' && (
<>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.status)}
</span>
<StatusSelector
defaultValue={currentFilters.status}
isMulti
onChange={(value) => {
updateQueryParams(
'status',
value?.map((v) => v.value).join('|')
);
}}
/>
</>
)}
<span className="text-lg font-semibold"> <span className="text-lg font-semibold">
{intl.formatMessage(messages.keywords)} {intl.formatMessage(messages.keywords)}
</span> </span>

View File

@@ -108,6 +108,7 @@ export const QueryFilterOptions = z.object({
voteCountGte: z.string().optional(), voteCountGte: z.string().optional(),
watchRegion: z.string().optional(), watchRegion: z.string().optional(),
watchProviders: z.string().optional(), watchProviders: z.string().optional(),
status: z.string().optional(),
}); });
export type FilterOptions = z.infer<typeof QueryFilterOptions>; export type FilterOptions = z.infer<typeof QueryFilterOptions>;
@@ -147,6 +148,10 @@ export const prepareFilterValues = (
filterValues.genre = values.genre; filterValues.genre = values.genre;
} }
if (values.status) {
filterValues.status = values.status;
}
if (values.keywords) { if (values.keywords) {
filterValues.keywords = values.keywords; filterValues.keywords = values.keywords;
} }

View File

@@ -28,7 +28,6 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover'; import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -76,7 +75,14 @@ const Discover = () => {
const updateSliders = async () => { const updateSliders = async () => {
try { try {
await axios.post('/api/v1/settings/discover', sliders); const res = await fetch('/api/v1/settings/discover', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sliders),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.updatesuccess), { addToast(intl.formatMessage(messages.updatesuccess), {
appearance: 'success', appearance: 'success',
@@ -94,7 +100,10 @@ const Discover = () => {
const resetSliders = async () => { const resetSliders = async () => {
try { try {
await axios.get('/api/v1/settings/discover/reset'); const res = await fetch('/api/v1/settings/discover/reset', {
method: 'GET',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.resetsuccess), { addToast(intl.formatMessage(messages.resetsuccess), {
appearance: 'success', appearance: 'success',

View File

@@ -11,7 +11,6 @@ import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import getConfig from 'next/config';
interface ExternalLinkBlockProps { interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv';
@@ -31,7 +30,6 @@ const ExternalLinkBlock = ({
mediaUrl, mediaUrl,
}: ExternalLinkBlockProps) => { }: ExternalLinkBlockProps) => {
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const { locale } = useLocale(); const { locale } = useLocale();
return ( return (
@@ -45,7 +43,8 @@ const ExternalLinkBlock = ({
> >
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? ( {settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
<PlexLogo /> <PlexLogo />
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? ( ) : settings.currentSettings.mediaServerType ===
MediaServerType.EMBY ? (
<EmbyLogo /> <EmbyLogo />
) : ( ) : (
<JellyfinLogo /> <JellyfinLogo />

View File

@@ -5,7 +5,6 @@ import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react'; import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment'; import type { default as IssueCommentType } from '@server/entity/IssueComment';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
@@ -49,7 +48,10 @@ const IssueComment = ({
const deleteComment = async () => { const deleteComment = async () => {
try { try {
await axios.delete(`/api/v1/issueComment/${comment.id}`); const res = await fetch(`/api/v1/issueComment/${comment.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
} catch (e) { } catch (e) {
// something went wrong deleting the comment // something went wrong deleting the comment
} finally { } finally {
@@ -175,9 +177,17 @@ const IssueComment = ({
<Formik <Formik
initialValues={{ newMessage: comment.message }} initialValues={{ newMessage: comment.message }}
onSubmit={async (values) => { onSubmit={async (values) => {
await axios.put(`/api/v1/issueComment/${comment.id}`, { const res = await fetch(
message: values.newMessage, `/api/v1/issueComment/${comment.id}`,
}); {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.newMessage }),
}
);
if (!res.ok) throw new Error();
if (onUpdate) { if (onUpdate) {
onUpdate(); onUpdate();

View File

@@ -11,7 +11,7 @@ import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import ErrorPage from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { import {
@@ -27,9 +27,7 @@ import { MediaServerType } from '@server/constants/server';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
@@ -109,14 +107,13 @@ const IssueDetails = () => {
(opt) => opt.issueType === issueData?.issueType (opt) => opt.issueType === issueData?.issueType
); );
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
if (!data || !issueData) { if (!data || !issueData) {
return <Error statusCode={404} />; return <ErrorPage statusCode={404} />;
} }
const belongsToUser = issueData.createdBy.id === currentUser?.id; const belongsToUser = issueData.createdBy.id === currentUser?.id;
@@ -125,9 +122,14 @@ const IssueDetails = () => {
const editFirstComment = async (newMessage: string) => { const editFirstComment = async (newMessage: string) => {
try { try {
await axios.put(`/api/v1/issueComment/${firstComment.id}`, { const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, {
message: newMessage, method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: newMessage }),
}); });
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), { addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), {
appearance: 'success', appearance: 'success',
@@ -144,7 +146,10 @@ const IssueDetails = () => {
const updateIssueStatus = async (newStatus: 'open' | 'resolved') => { const updateIssueStatus = async (newStatus: 'open' | 'resolved') => {
try { try {
await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`); const res = await fetch(`/api/v1/issue/${issueData.id}/${newStatus}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toaststatusupdated), { addToast(intl.formatMessage(messages.toaststatusupdated), {
appearance: 'success', appearance: 'success',
@@ -161,7 +166,10 @@ const IssueDetails = () => {
const deleteIssue = async () => { const deleteIssue = async () => {
try { try {
await axios.delete(`/api/v1/issue/${issueData.id}`); const res = await fetch(`/api/v1/issue/${issueData.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toastissuedeleted), { addToast(intl.formatMessage(messages.toastissuedeleted), {
appearance: 'success', appearance: 'success',
@@ -380,7 +388,8 @@ const IssueDetails = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' {settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.playonplex, { ? intl.formatMessage(messages.playonplex, {
mediaServerName: 'Emby', mediaServerName: 'Emby',
}) })
@@ -427,7 +436,8 @@ const IssueDetails = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' {settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.play4konplex, { ? intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Emby', mediaServerName: 'Emby',
}) })
@@ -490,9 +500,17 @@ const IssueDetails = () => {
}} }}
validationSchema={CommentSchema} validationSchema={CommentSchema}
onSubmit={async (values, { resetForm }) => { onSubmit={async (values, { resetForm }) => {
await axios.post(`/api/v1/issue/${issueData?.id}/comment`, { const res = await fetch(
message: values.message, `/api/v1/issue/${issueData?.id}/comment`,
}); {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.message }),
}
);
if (!res.ok) throw new Error();
revalidateIssue(); revalidateIssue();
resetForm(); resetForm();
}} }}
@@ -644,7 +662,8 @@ const IssueDetails = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' {settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.playonplex, { ? intl.formatMessage(messages.playonplex, {
mediaServerName: 'Emby', mediaServerName: 'Emby',
}) })
@@ -690,7 +709,8 @@ const IssueDetails = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby' {settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.play4konplex, { ? intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Emby', mediaServerName: 'Emby',
}) })

View File

@@ -11,7 +11,6 @@ import { MediaStatus } from '@server/constants/media';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import Link from 'next/link'; import Link from 'next/link';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -101,14 +100,22 @@ const CreateIssueModal = ({
validationSchema={CreateIssueModalSchema} validationSchema={CreateIssueModalSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const newIssue = await axios.post<Issue>('/api/v1/issue', { const res = await fetch('/api/v1/issue', {
issueType: values.selectedIssue.issueType, method: 'POST',
message: values.message, headers: {
mediaId: data?.mediaInfo?.id, 'Content-Type': 'application/json',
problemSeason: values.problemSeason, },
problemEpisode: body: JSON.stringify({
values.problemSeason > 0 ? values.problemEpisode : 0, issueType: values.selectedIssue.issueType,
message: values.message,
mediaId: data?.mediaInfo?.id,
problemSeason: values.problemSeason,
problemEpisode:
values.problemSeason > 0 ? values.problemEpisode : 0,
}),
}); });
if (!res.ok) throw new Error();
const newIssue: Issue = await res.json();
if (data) { if (data) {
addToast( addToast(
@@ -119,7 +126,7 @@ const CreateIssueModal = ({
strong: (msg: React.ReactNode) => <strong>{msg}</strong>, strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})} })}
</div> </div>
<Link href={`/issues/${newIssue.data.id}`} legacyBehavior> <Link href={`/issues/${newIssue.id}`} legacyBehavior>
<Button as="a" className="mt-4"> <Button as="a" className="mt-4">
<span>{intl.formatMessage(messages.toastviewissue)}</span> <span>{intl.formatMessage(messages.toastviewissue)}</span>
<ArrowRightCircleIcon /> <ArrowRightCircleIcon />

View File

@@ -7,7 +7,6 @@ import {
ClockIcon, ClockIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid'; import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import Image from 'next/image'; import Image from 'next/image';
import type { LinkProps } from 'next/link'; import type { LinkProps } from 'next/link';
import Link from 'next/link'; import Link from 'next/link';
@@ -39,9 +38,13 @@ const UserDropdown = () => {
const { user, revalidate } = useUser(); const { user, revalidate } = useUser();
const logout = async () => { const logout = async () => {
const response = await axios.post('/api/v1/auth/logout'); const res = await fetch('/api/v1/auth/logout', {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (response.data?.status === 'ok') { if (data?.status === 'ok') {
revalidate(); revalidate();
} }
}; };
@@ -87,9 +90,11 @@ const UserDropdown = () => {
<span className="truncate text-xl font-semibold text-gray-200"> <span className="truncate text-xl font-semibold text-gray-200">
{user?.displayName} {user?.displayName}
</span> </span>
<span className="truncate text-sm text-gray-400"> {user?.displayName?.toLowerCase() !== user?.email && (
{user?.email} <span className="truncate text-sm text-gray-400">
</span> {user?.email}
</span>
)}
</div> </div>
</div> </div>
{user && <MiniQuotaDisplay userId={user?.id} />} {user && <MiniQuotaDisplay userId={user?.id} />}

View File

@@ -17,47 +17,45 @@ interface UserWarningsProps {
const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => { const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
const intl = useIntl(); const intl = useIntl();
const { user } = useUser(); const { user } = useUser();
if (!user) { //check if a user has warnings
if (!user || !user.warnings || user.warnings.length === 0) {
return null; return null;
} }
let res = null; let res = null;
//check if a user has warnings user.warnings.forEach((warning) => {
if (user.warnings.length > 0) { let link = '';
user.warnings.forEach((warning) => { let warningText = '';
let link = ''; let warningTitle = '';
let warningText = ''; switch (warning) {
let warningTitle = ''; case 'userEmailRequired':
switch (warning) { link = '/profile/settings/';
case 'userEmailRequired': warningTitle = 'Profile is incomplete';
link = '/profile/settings/'; warningText = intl.formatMessage(messages.emailRequired);
warningTitle = 'Profile is incomplete'; }
warningText = intl.formatMessage(messages.emailRequired);
}
res = ( res = (
<Link <Link
href={link} href={link}
onClick={onClick} onClick={onClick}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && onClick) { if (e.key === 'Enter' && onClick) {
onClick(); onClick();
} }
}} }}
role="button" role="button"
tabIndex={0} tabIndex={0}
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400" className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
> >
<ExclamationTriangleIcon className="h-6 w-6" /> <ExclamationTriangleIcon className="h-6 w-6" />
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0"> <div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
<span className="font-bold">{warningTitle}</span> <span className="font-bold">{warningTitle}</span>
<span className="truncate">{warningText}</span> <span className="truncate">{warningText}</span>
</div> </div>
</Link> </Link>
); );
}); });
}
return res; return res;
}; };

View File

@@ -2,7 +2,6 @@ import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import * as Yup from 'yup'; import * as Yup from 'yup';
@@ -58,11 +57,18 @@ const AddEmailModal: React.FC<AddEmailModalProps> = ({
validationSchema={EmailSettingsSchema} validationSchema={EmailSettingsSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/auth/jellyfin', { const res = await fetch('/api/v1/auth/jellyfin', {
username: username, method: 'POST',
password: password, headers: {
email: values.email, 'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
password: password,
email: values.email,
}),
}); });
if (!res.ok) throw new Error();
onSave(); onSave();
} catch (e) { } catch (e) {

View File

@@ -4,10 +4,9 @@ import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { InformationCircleIcon } from '@heroicons/react/24/solid'; import { InformationCircleIcon } from '@heroicons/react/24/solid';
import { ApiErrorCode } from '@server/constants/error'; import { ApiErrorCode } from '@server/constants/error';
import axios from 'axios'; import { MediaServerType, ServerType } from '@server/constants/server';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config'; import { FormattedMessage, useIntl } from 'react-intl';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup'; import * as Yup from 'yup';
@@ -27,6 +26,7 @@ const messages = defineMessages('components.Login', {
validationemailformat: 'Valid email required', validationemailformat: 'Valid email required',
validationusernamerequired: 'Username required', validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password required', validationpasswordrequired: 'Password required',
validationservertyperequired: 'Please select a server type',
validationHostnameRequired: 'You must provide a valid hostname or IP address', validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number', validationPortRequired: 'You must provide a valid port number',
validationUrlTrailingSlash: 'URL must not end in a trailing slash', validationUrlTrailingSlash: 'URL must not end in a trailing slash',
@@ -41,42 +41,51 @@ const messages = defineMessages('components.Login', {
initialsigningin: 'Connecting…', initialsigningin: 'Connecting…',
initialsignin: 'Connect', initialsignin: 'Connect',
forgotpassword: 'Forgot Password?', forgotpassword: 'Forgot Password?',
servertype: 'Server Type',
back: 'Go back',
}); });
interface JellyfinLoginProps { interface JellyfinLoginProps {
revalidate: () => void; revalidate: () => void;
initial?: boolean; initial?: boolean;
serverType?: MediaServerType;
onCancel?: () => void;
} }
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
revalidate, revalidate,
initial, initial,
serverType,
onCancel,
}) => { }) => {
const toasts = useToasts(); const toasts = useToasts();
const intl = useIntl(); const intl = useIntl();
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const mediaServerFormatValues = {
mediaServerName:
serverType === MediaServerType.JELLYFIN
? ServerType.JELLYFIN
: serverType === MediaServerType.EMBY
? ServerType.EMBY
: 'Media Server',
};
if (initial) { if (initial) {
const LoginSchema = Yup.object().shape({ const LoginSchema = Yup.object().shape({
hostname: Yup.string().required( hostname: Yup.string().required(
intl.formatMessage(messages.validationhostrequired, { intl.formatMessage(
mediaServerName: messages.validationhostrequired,
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', mediaServerFormatValues
}) )
), ),
port: Yup.number().required( port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired) intl.formatMessage(messages.validationPortRequired)
), ),
urlBase: Yup.string() urlBase: Yup.string().matches(
.matches( /^(.*[^/])$/,
/^(\/[^/].*[^/]$)/, intl.formatMessage(messages.validationUrlBaseTrailingSlash)
intl.formatMessage(messages.validationUrlBaseLeadingSlash) ),
)
.matches(
/^(.*[^/])$/,
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
),
email: Yup.string() email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat)) .email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)), .required(intl.formatMessage(messages.validationemailrequired)),
@@ -86,11 +95,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
password: Yup.string(), password: Yup.string(),
}); });
const mediaServerFormatValues = {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
};
return ( return (
<Formik <Formik
initialValues={{ initialValues={{
@@ -105,18 +109,38 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
validationSchema={LoginSchema} validationSchema={LoginSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/auth/jellyfin', { // Check if serverType is either 'Jellyfin' or 'Emby'
username: values.username, // if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
password: values.password, // throw new Error('Invalid serverType'); // You can customize the error message
hostname: values.hostname, // }
port: values.port,
useSsl: values.useSsl, const res = await fetch('/api/v1/auth/jellyfin', {
urlBase: values.urlBase, method: 'POST',
email: values.email, headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: values.username,
password: values.password,
hostname: values.hostname,
port: values.port,
useSsl: values.useSsl,
urlBase: values.urlBase,
email: values.email,
serverType: serverType,
}),
}); });
if (!res.ok) throw new Error(res.statusText, { cause: res });
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
let errorMessage = null; let errorMessage = null;
switch (e.response?.data?.message) { switch (errorData?.message) {
case ApiErrorCode.InvalidUrl: case ApiErrorCode.InvalidUrl:
errorMessage = messages.invalidurlerror; errorMessage = messages.invalidurlerror;
break; break;
@@ -299,7 +323,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
</div> </div>
</div> </div>
<div className="mt-8 border-t border-gray-700 pt-5"> <div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end"> <div className="flex flex-row-reverse justify-between">
<span className="inline-flex rounded-md shadow-sm"> <span className="inline-flex rounded-md shadow-sm">
<Button <Button
buttonType="primary" buttonType="primary"
@@ -311,6 +335,13 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
: intl.formatMessage(messages.signin)} : intl.formatMessage(messages.signin)}
</Button> </Button>
</span> </span>
{onCancel && (
<span className="inline-flex rounded-md shadow-sm">
<Button buttonType="default" onClick={() => onCancel()}>
<FormattedMessage {...messages.back} />
</Button>
</span>
)}
</div> </div>
</div> </div>
</Form> </Form>
@@ -339,11 +370,18 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
validationSchema={LoginSchema} validationSchema={LoginSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/auth/jellyfin', { const res = await fetch('/api/v1/auth/jellyfin', {
username: values.username, method: 'POST',
password: values.password, headers: {
email: values.username, 'Content-Type': 'application/json',
},
body: JSON.stringify({
username: values.username,
password: values.password,
email: values.username,
}),
}); });
if (!res.ok) throw new Error();
} catch (e) { } catch (e) {
toasts.addToast( toasts.addToast(
intl.formatMessage( intl.formatMessage(
@@ -409,7 +447,8 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
jellyfinForgotPasswordUrl jellyfinForgotPasswordUrl
? `${jellyfinForgotPasswordUrl}` ? `${jellyfinForgotPasswordUrl}`
: `${baseUrl}/web/index.html#!/${ : `${baseUrl}/web/index.html#!/${
process.env.JELLYFIN_TYPE === 'emby' settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? 'startup/' ? 'startup/'
: '' : ''
}forgotpassword.html` }forgotpassword.html`

View File

@@ -6,7 +6,6 @@ import {
ArrowLeftOnRectangleIcon, ArrowLeftOnRectangleIcon,
LifebuoyIcon, LifebuoyIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
@@ -56,10 +55,17 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
validationSchema={LoginSchema} validationSchema={LoginSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/auth/local', { const res = await fetch('/api/v1/auth/local', {
email: values.email, method: 'POST',
password: values.password, headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: values.email,
password: values.password,
}),
}); });
if (!res.ok) throw new Error();
} catch (e) { } catch (e) {
setLoginError(intl.formatMessage(messages.loginerror)); setLoginError(intl.formatMessage(messages.loginerror));
} finally { } finally {

View File

@@ -10,8 +10,6 @@ import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid'; import { XCircleIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import getConfig from 'next/config';
import { useRouter } from 'next/dist/client/router'; import { useRouter } from 'next/dist/client/router';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -35,7 +33,6 @@ const Login = () => {
const { user, revalidate } = useUser(); const { user, revalidate } = useUser();
const router = useRouter(); const router = useRouter();
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
// Effect that is triggered when the `authToken` comes back from the Plex OAuth // Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to sign in. If we get a success message, we will // We take the token and attempt to sign in. If we get a success message, we will
@@ -44,13 +41,28 @@ const Login = () => {
const login = async () => { const login = async () => {
setProcessing(true); setProcessing(true);
try { try {
const response = await axios.post('/api/v1/auth/plex', { authToken }); const res = await fetch('/api/v1/auth/plex', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ authToken }),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
const data = await res.json();
if (response.data?.id) { if (data?.id) {
revalidate(); revalidate();
} }
} catch (e) { } catch (e) {
setError(e.response.data.message); let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
setError(errorData?.message);
setAuthToken(undefined); setAuthToken(undefined);
setProcessing(false); setProcessing(false);
} }
@@ -74,6 +86,15 @@ const Login = () => {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const mediaServerFormatValues = {
mediaServerName:
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: undefined,
};
return ( return (
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14"> <div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
<PageTitle title={intl.formatMessage(messages.signin)} /> <PageTitle title={intl.formatMessage(messages.signin)} />
@@ -140,12 +161,10 @@ const Login = () => {
{settings.currentSettings.mediaServerType == {settings.currentSettings.mediaServerType ==
MediaServerType.PLEX MediaServerType.PLEX
? intl.formatMessage(messages.signinwithplex) ? intl.formatMessage(messages.signinwithplex)
: intl.formatMessage(messages.signinwithjellyfin, { : intl.formatMessage(
mediaServerName: messages.signinwithjellyfin,
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' mediaServerFormatValues
? 'Emby' )}
: 'Jellyfin',
})}
</button> </button>
<AccordionContent isOpen={openIndexes.includes(0)}> <AccordionContent isOpen={openIndexes.includes(0)}>
<div className="px-10 py-8"> <div className="px-10 py-8">

View File

@@ -26,8 +26,6 @@ import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfa
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import getConfig from 'next/config';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -96,7 +94,6 @@ const ManageSlideOver = ({
const { user: currentUser, hasPermission } = useUser(); const { user: currentUser, hasPermission } = useUser();
const intl = useIntl(); const intl = useIntl();
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const { data: watchData } = useSWR<MediaWatchDataResponse>( const { data: watchData } = useSWR<MediaWatchDataResponse>(
settings.currentSettings.mediaServerType === MediaServerType.PLEX && settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
data.mediaInfo && data.mediaInfo &&
@@ -113,16 +110,29 @@ const ManageSlideOver = ({
const deleteMedia = async () => { const deleteMedia = async () => {
if (data.mediaInfo) { if (data.mediaInfo) {
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); const res = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidate(); revalidate();
onClose();
} }
}; };
const deleteMediaFile = async () => { const deleteMediaFile = async () => {
if (data.mediaInfo) { if (data.mediaInfo) {
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`); const res1 = await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, {
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); method: 'DELETE',
});
if (!res1.ok) throw new Error();
const res2 = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
method: 'DELETE',
});
if (!res2.ok) throw new Error();
revalidate(); revalidate();
onClose();
} }
}; };
@@ -149,9 +159,16 @@ const ManageSlideOver = ({
const markAvailable = async (is4k = false) => { const markAvailable = async (is4k = false) => {
if (data.mediaInfo) { if (data.mediaInfo) {
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, { const res = await fetch(`/api/v1/media/${data.mediaInfo?.id}/available`, {
is4k, method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
is4k,
}),
}); });
if (!res.ok) throw new Error();
revalidate(); revalidate();
} }
}; };
@@ -642,7 +659,8 @@ const ManageSlideOver = ({
mediaType === 'movie' ? messages.movie : messages.tvshow mediaType === 'movie' ? messages.movie : messages.tvshow
), ),
mediaServerName: mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? 'Emby' ? 'Emby'
: settings.currentSettings.mediaServerType === : settings.currentSettings.mediaServerType ===
MediaServerType.PLEX MediaServerType.PLEX

View File

@@ -57,44 +57,48 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
> >
<div style={{ paddingBottom: '150%' }}> <div style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex h-full w-full flex-col items-center p-2"> <div className="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div className="relative z-10 flex h-full flex-wrap items-center justify-center opacity-30"> <div className="relative z-10 grid h-full w-full grid-cols-2 items-center justify-center gap-2 opacity-30">
{posters[0] && ( {posters[0] && (
<div className="w-1/2 p-1"> <div className="">
<Image <Image
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`} src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
alt="" alt=""
className="w-full rounded-md" className="rounded-md"
fill width={300}
height={450}
/> />
</div> </div>
)} )}
{posters[1] && ( {posters[1] && (
<div className="w-1/2 p-1"> <div className="">
<Image <Image
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`} src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
alt="" alt=""
className="w-full rounded-md" className="rounded-md"
fill width={300}
height={450}
/> />
</div> </div>
)} )}
{posters[2] && ( {posters[2] && (
<div className="w-1/2 p-1"> <div className="">
<Image <Image
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`} src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
alt="" alt=""
className="w-full rounded-md" className="rounded-md"
fill width={300}
height={450}
/> />
</div> </div>
)} )}
{posters[3] && ( {posters[3] && (
<div className="w-1/2 p-1"> <div className="">
<Image <Image
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`} src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
alt="" alt=""
className="w-full rounded-md" className="rounded-md"
fill width={300}
height={450}
/> />
</div> </div>
)} )}

View File

@@ -3,6 +3,7 @@ import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
import RTFresh from '@app/assets/rt_fresh.svg'; import RTFresh from '@app/assets/rt_fresh.svg';
import RTRotten from '@app/assets/rt_rotten.svg'; import RTRotten from '@app/assets/rt_rotten.svg';
import ImdbLogo from '@app/assets/services/imdb.svg'; import ImdbLogo from '@app/assets/services/imdb.svg';
import Spinner from '@app/assets/spinner.svg';
import TmdbLogo from '@app/assets/tmdb_logo.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage'; import CachedImage from '@app/components/Common/CachedImage';
@@ -25,7 +26,7 @@ import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error'; import ErrorPage from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers'; import { sortCrewPriority } from '@app/utils/creditHelpers';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
@@ -41,20 +42,22 @@ import {
import { import {
ChevronDoubleDownIcon, ChevronDoubleDownIcon,
ChevronDoubleUpIcon, ChevronDoubleUpIcon,
MinusCircleIcon,
StarIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { type RatingResponse } from '@server/api/ratings'; import { type RatingResponse } from '@server/api/ratings';
import { IssueStatus } from '@server/constants/issue'; import { IssueStatus } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import { countries } from 'country-flag-icons'; import { countries } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css'; import 'country-flag-icons/3x2/flags.css';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import getConfig from 'next/config';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages('components.MovieDetails', { const messages = defineMessages('components.MovieDetails', {
@@ -94,6 +97,12 @@ const messages = defineMessages('components.MovieDetails', {
rtaudiencescore: 'Rotten Tomatoes Audience Score', rtaudiencescore: 'Rotten Tomatoes Audience Score',
tmdbuserscore: 'TMDB User Score', tmdbuserscore: 'TMDB User Score',
imdbuserscore: 'IMDB User Score', imdbuserscore: 'IMDB User Score',
watchlistSuccess: '<strong>{title}</strong> added to watchlist successfully!',
watchlistDeleted:
'<strong>{title}</strong> Removed from watchlist successfully!',
watchlistError: 'Something went wrong try again.',
removefromwatchlist: 'Remove From Watchlist',
addtowatchlist: 'Add To Watchlist',
}); });
interface MovieDetailsProps { interface MovieDetailsProps {
@@ -112,7 +121,11 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const minStudios = 3; const minStudios = 3;
const [showMoreStudios, setShowMoreStudios] = useState(false); const [showMoreStudios, setShowMoreStudios] = useState(false);
const [showIssueModal, setShowIssueModal] = useState(false); const [showIssueModal, setShowIssueModal] = useState(false);
const { publicRuntimeConfig } = getConfig(); const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
!movie?.onUserWatchlist
);
const { addToast } = useToasts();
const { const {
data, data,
@@ -154,7 +167,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
} }
if (!data) { if (!data) {
return <Error statusCode={404} />; return <ErrorPage statusCode={404} />;
} }
const showAllStudios = data.productionCompanies.length <= minStudios + 1; const showAllStudios = data.productionCompanies.length <= minStudios + 1;
@@ -264,7 +277,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
?.flatrate ?? []; ?.flatrate ?? [];
function getAvalaibleMediaServerName() { function getAvalaibleMediaServerName() {
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') { if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
} }
@@ -276,8 +289,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
} }
function getAvalaible4kMediaServerName() { function getAvalaible4kMediaServerName() {
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') { if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play4k, { mediaServerName: 'Emby' }); return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
} }
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) { if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
@@ -287,6 +300,80 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' }); return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' });
} }
const onClickWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true);
const res = await fetch('/api/v1/watchlist', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
tmdbId: movie?.id,
mediaType: MediaType.MOVIE,
title: movie?.title,
}),
});
if (!res.ok) {
addToast(intl.formatMessage(messages.watchlistError), {
appearance: 'error',
autoDismiss: true,
});
setIsUpdating(false);
return;
}
const data = await res.json();
if (data) {
addToast(
<span>
{intl.formatMessage(messages.watchlistSuccess, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
setIsUpdating(false);
setToggleWatchlist((prevState) => !prevState);
};
const onClickDeleteWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true);
try {
const res = await fetch(`/api/v1/watchlist/${movie?.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(messages.watchlistDeleted, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
}
} catch (e) {
addToast(intl.formatMessage(messages.watchlistError), {
appearance: 'error',
autoDismiss: true,
});
} finally {
setIsUpdating(false);
setToggleWatchlist((prevState) => !prevState);
}
};
return ( return (
<div <div
className="media-page" className="media-page"
@@ -408,6 +495,40 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</span> </span>
</div> </div>
<div className="media-actions"> <div className="media-actions">
<>
{toggleWatchlist ? (
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
<Button
buttonType={'ghost'}
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickWatchlistBtn}
>
{isUpdating ? (
<Spinner className="h-3" />
) : (
<StarIcon className={'h-3 text-amber-300'} />
)}
</Button>
</Tooltip>
) : (
<Tooltip
content={intl.formatMessage(messages.removefromwatchlist)}
>
<Button
className="z-40 mr-2"
buttonSize={'md'}
onClick={onClickDeleteWatchlistBtn}
>
{isUpdating ? (
<Spinner className="h-3" />
) : (
<MinusCircleIcon className={'h-3'} />
)}
</Button>
</Tooltip>
)}
</>
<PlayButton links={mediaLinks} /> <PlayButton links={mediaLinks} />
<RequestButton <RequestButton
mediaType="movie" mediaType="movie"

View File

@@ -17,7 +17,6 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -53,7 +52,10 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
const updateRequest = async (type: 'approve' | 'decline'): Promise<void> => { const updateRequest = async (type: 'approve' | 'decline'): Promise<void> => {
setIsUpdating(true); setIsUpdating(true);
await axios.post(`/api/v1/request/${request.id}/${type}`); const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
if (onUpdate) { if (onUpdate) {
onUpdate(); onUpdate();
@@ -63,7 +65,10 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
const deleteRequest = async () => { const deleteRequest = async () => {
setIsUpdating(true); setIsUpdating(true);
await axios.delete(`/api/v1/request/${request.id}`); const res = await fetch(`/api/v1/request/${request.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
if (onUpdate) { if (onUpdate) {
onUpdate(); onUpdate();

View File

@@ -13,7 +13,6 @@ import {
import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type Media from '@server/entity/Media'; import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -94,9 +93,13 @@ const RequestButton = ({
request: MediaRequest, request: MediaRequest,
type: 'approve' | 'decline' type: 'approve' | 'decline'
) => { ) => {
const response = await axios.post(`/api/v1/request/${request.id}/${type}`); const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (response) { if (data) {
onUpdate(); onUpdate();
} }
}; };
@@ -111,7 +114,11 @@ const RequestButton = ({
await Promise.all( await Promise.all(
requests.map(async (request) => { requests.map(async (request) => {
return axios.post(`/api/v1/request/${request.id}/${type}`); const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
return res.json();
}) })
); );

View File

@@ -19,9 +19,9 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -59,7 +59,7 @@ const RequestCardPlaceholder = () => {
}; };
interface RequestCardErrorProps { interface RequestCardErrorProps {
requestData?: MediaRequest; requestData?: NonFunctionProperties<MediaRequest>;
} }
const RequestCardError = ({ requestData }: RequestCardErrorProps) => { const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
@@ -74,7 +74,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
}); });
const deleteRequest = async () => { const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${requestData?.media.id}`); const res = await fetch(`/api/v1/media/${requestData?.media.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'); mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
}; };
@@ -211,7 +214,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
}; };
interface RequestCardProps { interface RequestCardProps {
request: MediaRequest; request: NonFunctionProperties<MediaRequest>;
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
} }
@@ -236,16 +239,19 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
data: requestData, data: requestData,
error: requestError, error: requestError,
mutate: revalidate, mutate: revalidate,
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, { } = useSWR<NonFunctionProperties<MediaRequest>>(
fallbackData: request, `/api/v1/request/${request.id}`,
refreshInterval: refreshIntervalHelper( {
{ fallbackData: request,
downloadStatus: request.media.downloadStatus, refreshInterval: refreshIntervalHelper(
downloadStatus4k: request.media.downloadStatus4k, {
}, downloadStatus: request.media.downloadStatus,
15000 downloadStatus4k: request.media.downloadStatus4k,
), },
}); 15000
),
}
);
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
mediaUrl: requestData?.media?.mediaUrl, mediaUrl: requestData?.media?.mediaUrl,
@@ -255,15 +261,22 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
}); });
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.post(`/api/v1/request/${request.id}/${type}`); const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (response) { if (data) {
revalidate(); revalidate();
} }
}; };
const deleteRequest = async () => { const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`); const res = await fetch(`/api/v1/request/${request.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
}; };
@@ -271,9 +284,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
setRetrying(true); setRetrying(true);
try { try {
const response = await axios.post(`/api/v1/request/${request.id}/retry`); const res = await fetch(`/api/v1/request/${request.id}/retry`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (response) { if (data) {
revalidate(); revalidate();
} }
} catch (e) { } catch (e) {

View File

@@ -18,9 +18,9 @@ import {
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
@@ -43,6 +43,7 @@ const messages = defineMessages('components.RequestList.RequestItem', {
tmdbid: 'TMDB ID', tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID', tvdbid: 'TheTVDB ID',
unknowntitle: 'Unknown Title', unknowntitle: 'Unknown Title',
profileName: 'Profile',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -50,7 +51,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
}; };
interface RequestItemErrorProps { interface RequestItemErrorProps {
requestData?: MediaRequest; requestData?: NonFunctionProperties<MediaRequest>;
revalidateList: () => void; revalidateList: () => void;
} }
@@ -62,7 +63,10 @@ const RequestItemError = ({
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const deleteRequest = async () => { const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${requestData?.media.id}`); const res = await fetch(`/api/v1/media/${requestData?.media.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidateList(); revalidateList();
}; };
@@ -283,7 +287,7 @@ const RequestItemError = ({
}; };
interface RequestItemProps { interface RequestItemProps {
request: MediaRequest; request: NonFunctionProperties<MediaRequest> & { profileName?: string };
revalidateList: () => void; revalidateList: () => void;
} }
@@ -302,32 +306,38 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? url : null inView ? url : null
); );
const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>( const { data: requestData, mutate: revalidate } = useSWR<
`/api/v1/request/${request.id}`, NonFunctionProperties<MediaRequest>
{ >(`/api/v1/request/${request.id}`, {
fallbackData: request, fallbackData: request,
refreshInterval: refreshIntervalHelper( refreshInterval: refreshIntervalHelper(
{ {
downloadStatus: request.media.downloadStatus, downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k, downloadStatus4k: request.media.downloadStatus4k,
}, },
15000 15000
), ),
} });
);
const [isRetrying, setRetrying] = useState(false); const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.post(`/api/v1/request/${request.id}/${type}`); const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
if (response) { if (data) {
revalidate(); revalidate();
} }
}; };
const deleteRequest = async () => { const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`); const res = await fetch(`/api/v1/request/${request.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
revalidateList(); revalidateList();
}; };
@@ -336,7 +346,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
setRetrying(true); setRetrying(true);
try { try {
const result = await axios.post(`/api/v1/request/${request.id}/retry`); const res = await fetch(`/api/v1/request/${request.id}/retry`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const result = await res.json();
revalidate(result.data); revalidate(result.data);
} catch (e) { } catch (e) {
addToast(intl.formatMessage(messages.failedretry), { addToast(intl.formatMessage(messages.failedretry), {
@@ -387,7 +402,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
setShowEditModal(false); setShowEditModal(false);
}} }}
/> />
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row"> <div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-2 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
{title.backdropPath && ( {title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3"> <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage <CachedImage
@@ -468,7 +483,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
)} )}
</div> </div>
</div> </div>
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0"> <div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center gap-1 overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="card-field"> <div className="card-field">
<span className="card-field-name"> <span className="card-field-name">
{intl.formatMessage(globalMessages.status)} {intl.formatMessage(globalMessages.status)}
@@ -618,6 +633,16 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
</span> </span>
</div> </div>
)} )}
{request.profileName && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.profileName)}
</span>
<span className="flex truncate text-sm text-gray-300">
{request.profileName}
</span>
</div>
)}
</div> </div>
</div> </div>
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0"> <div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">

View File

@@ -13,7 +13,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import type { Collection } from '@server/models/Collection'; import type { Collection } from '@server/models/Collection';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -197,12 +196,19 @@ const CollectionRequestModal = ({
( (
data?.parts.filter((part) => selectedParts.includes(part.id)) ?? [] data?.parts.filter((part) => selectedParts.includes(part.id)) ?? []
).map(async (part) => { ).map(async (part) => {
await axios.post<MediaRequest>('/api/v1/request', { const res = await fetch('/api/v1/request', {
mediaId: part.id, method: 'POST',
mediaType: 'movie', headers: {
is4k, 'Content-Type': 'application/json',
...overrideParams, },
body: JSON.stringify({
mediaId: part.id,
mediaType: 'movie',
is4k,
...overrideParams,
}),
}); });
if (!res.ok) throw new Error();
}) })
); );

View File

@@ -8,10 +8,10 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -39,7 +39,7 @@ const messages = defineMessages('components.RequestModal', {
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> { interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
tmdbId: number; tmdbId: number;
is4k?: boolean; is4k?: boolean;
editRequest?: MediaRequest; editRequest?: NonFunctionProperties<MediaRequest>;
onCancel?: () => void; onCancel?: () => void;
onComplete?: (newStatus: MediaStatus) => void; onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void; onUpdating?: (isUpdating: boolean) => void;
@@ -89,15 +89,23 @@ const MovieRequestModal = ({
tags: requestOverrides.tags, tags: requestOverrides.tags,
}; };
} }
const response = await axios.post<MediaRequest>('/api/v1/request', { const res = await fetch('/api/v1/request', {
mediaId: data?.id, method: 'POST',
mediaType: 'movie', headers: {
is4k, 'Content-Type': 'application/json',
...overrideParams, },
body: JSON.stringify({
mediaId: data?.id,
mediaType: 'movie',
is4k,
...overrideParams,
}),
}); });
if (!res.ok) throw new Error();
const mediaRequest: MediaRequest = await res.json();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (response.data) { if (mediaRequest) {
if (onComplete) { if (onComplete) {
onComplete( onComplete(
hasPermission( hasPermission(
@@ -136,12 +144,14 @@ const MovieRequestModal = ({
setIsUpdating(true); setIsUpdating(true);
try { try {
const response = await axios.delete<MediaRequest>( const res = await fetch(`/api/v1/request/${editRequest?.id}`, {
`/api/v1/request/${editRequest?.id}` method: 'DELETE',
); });
if (!res.ok) throw new Error();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (response.status === 204) { if (res.status === 204) {
if (onComplete) { if (onComplete) {
onComplete(MediaStatus.UNKNOWN); onComplete(MediaStatus.UNKNOWN);
} }
@@ -164,17 +174,27 @@ const MovieRequestModal = ({
setIsUpdating(true); setIsUpdating(true);
try { try {
await axios.put(`/api/v1/request/${editRequest?.id}`, { const res = await fetch(`/api/v1/request/${editRequest?.id}`, {
mediaType: 'movie', method: 'PUT',
serverId: requestOverrides?.server, headers: {
profileId: requestOverrides?.profile, 'Content-Type': 'application/json',
rootFolder: requestOverrides?.folder, },
userId: requestOverrides?.user?.id, body: JSON.stringify({
tags: requestOverrides?.tags, mediaType: 'movie',
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
}),
}); });
if (!res.ok) throw new Error();
if (alsoApproveRequest) { if (alsoApproveRequest) {
await axios.post(`/api/v1/request/${editRequest?.id}/approve`); const res = await fetch(`/api/v1/request/${editRequest?.id}/approve`, {
method: 'POST',
});
if (!res.ok) throw new Error();
} }
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');

View File

@@ -13,10 +13,10 @@ import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type SeasonRequest from '@server/entity/SeasonRequest'; import type SeasonRequest from '@server/entity/SeasonRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@@ -58,7 +58,7 @@ interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
onComplete?: (newStatus: MediaStatus) => void; onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void; onUpdating?: (isUpdating: boolean) => void;
is4k?: boolean; is4k?: boolean;
editRequest?: MediaRequest; editRequest?: NonFunctionProperties<MediaRequest>;
} }
const TvRequestModal = ({ const TvRequestModal = ({
@@ -111,22 +111,35 @@ const TvRequestModal = ({
try { try {
if (selectedSeasons.length > 0) { if (selectedSeasons.length > 0) {
await axios.put(`/api/v1/request/${editRequest.id}`, { const res = await fetch(`/api/v1/request/${editRequest.id}`, {
mediaType: 'tv', method: 'PUT',
serverId: requestOverrides?.server, headers: {
profileId: requestOverrides?.profile, 'Content-Type': 'application/json',
rootFolder: requestOverrides?.folder, },
languageProfileId: requestOverrides?.language, body: JSON.stringify({
userId: requestOverrides?.user?.id, mediaType: 'tv',
tags: requestOverrides?.tags, serverId: requestOverrides?.server,
seasons: selectedSeasons, profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
languageProfileId: requestOverrides?.language,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
seasons: selectedSeasons,
}),
}); });
if (!res.ok) throw new Error();
if (alsoApproveRequest) { if (alsoApproveRequest) {
await axios.post(`/api/v1/request/${editRequest.id}/approve`); const res = await fetch(`/api/v1/request/${editRequest.id}/approve`, {
method: 'POST',
});
if (!res.ok) throw new Error();
} }
} else { } else {
await axios.delete(`/api/v1/request/${editRequest.id}`); const res = await fetch(`/api/v1/request/${editRequest.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
} }
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
@@ -191,23 +204,32 @@ const TvRequestModal = ({
tags: requestOverrides.tags, tags: requestOverrides.tags,
}; };
} }
const response = await axios.post<MediaRequest>('/api/v1/request', { const res = await fetch('/api/v1/request', {
mediaId: data?.id, method: 'POST',
tvdbId: tvdbId ?? data?.externalIds.tvdbId, headers: {
mediaType: 'tv', 'Content-Type': 'application/json',
is4k, },
seasons: settings.currentSettings.partialRequestsEnabled body: JSON.stringify({
? selectedSeasons mediaId: data?.id,
: getAllSeasons().filter( tvdbId: tvdbId ?? data?.externalIds.tvdbId,
(season) => !getAllRequestedSeasons().includes(season) mediaType: 'tv',
), is4k,
...overrideParams, seasons: settings.currentSettings.partialRequestsEnabled
? selectedSeasons
: getAllSeasons().filter(
(season) => !getAllRequestedSeasons().includes(season)
),
...overrideParams,
}),
}); });
if (!res.ok) throw new Error();
const mediaRequest: MediaRequest = await res.json();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (response.data) { if (mediaRequest) {
if (onComplete) { if (onComplete) {
onComplete(response.data.media.status); onComplete(mediaRequest.media.status);
} }
addToast( addToast(
<span> <span>

View File

@@ -4,13 +4,14 @@ import TvRequestModal from '@app/components/RequestModal/TvRequestModal';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import type { MediaStatus } from '@server/constants/media'; import type { MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
interface RequestModalProps { interface RequestModalProps {
show: boolean; show: boolean;
type: 'movie' | 'tv' | 'collection'; type: 'movie' | 'tv' | 'collection';
tmdbId: number; tmdbId: number;
is4k?: boolean; is4k?: boolean;
editRequest?: MediaRequest; editRequest?: NonFunctionProperties<MediaRequest>;
onComplete?: (newStatus: MediaStatus) => void; onComplete?: (newStatus: MediaStatus) => void;
onCancel?: () => void; onCancel?: () => void;
onUpdating?: (isUpdating: boolean) => void; onUpdating?: (isUpdating: boolean) => void;

View File

@@ -4,7 +4,6 @@ import PageTitle from '@app/components/Common/PageTitle';
import LanguagePicker from '@app/components/Layout/LanguagePicker'; import LanguagePicker from '@app/components/Layout/LanguagePicker';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ArrowLeftIcon, EnvelopeIcon } from '@heroicons/react/24/solid'; import { ArrowLeftIcon, EnvelopeIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
@@ -85,14 +84,18 @@ const ResetPassword = () => {
}} }}
validationSchema={ResetSchema} validationSchema={ResetSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
const response = await axios.post( const res = await fetch(`/api/v1/auth/reset-password`, {
`/api/v1/auth/reset-password`, method: 'POST',
{ headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: values.email, email: values.email,
} }),
); });
if (!res.ok) throw new Error();
if (response.status === 200) { if (res.status === 200) {
setSubmitted(true); setSubmitted(true);
} }
}} }}

View File

@@ -5,7 +5,6 @@ import LanguagePicker from '@app/components/Layout/LanguagePicker';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { LifebuoyIcon } from '@heroicons/react/24/outline'; import { LifebuoyIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Form, Formik } from 'formik'; import { Form, Formik } from 'formik';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
@@ -100,14 +99,21 @@ const ResetPassword = () => {
}} }}
validationSchema={ResetSchema} validationSchema={ResetSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
const response = await axios.post( const res = await fetch(
`/api/v1/auth/reset-password/${guid}`, `/api/v1/auth/reset-password/${guid}`,
{ {
password: values.password, method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
password: values.password,
}),
} }
); );
if (!res.ok) throw new Error();
if (response.status === 200) { if (res.status === 200) {
setSubmitted(true); setSubmitted(true);
} }
}} }}

View File

@@ -18,7 +18,6 @@ import type {
ProductionCompany, ProductionCompany,
WatchProviderDetails, WatchProviderDetails,
} from '@server/models/common'; } from '@server/models/common';
import axios from 'axios';
import { orderBy } from 'lodash'; import { orderBy } from 'lodash';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -34,6 +33,13 @@ const messages = defineMessages('components.Selector', {
nooptions: 'No results.', nooptions: 'No results.',
showmore: 'Show More', showmore: 'Show More',
showless: 'Show Less', showless: 'Show Less',
searchStatus: 'Select status...',
returningSeries: 'Returning Series',
planned: 'Planned',
inProduction: 'In Production',
ended: 'Ended',
canceled: 'Canceled',
pilot: 'Pilot',
}); });
type SingleVal = { type SingleVal = {
@@ -69,11 +75,9 @@ export const CompanySelector = ({
return; return;
} }
const response = await axios.get<ProductionCompany>( const res = await fetch(`/api/v1/studio/${defaultValue}`);
`/api/v1/studio/${defaultValue}` if (!res.ok) throw new Error();
); const studio: ProductionCompany = await res.json();
const studio = response.data;
setDefaultDataValue([ setDefaultDataValue([
{ {
@@ -91,16 +95,15 @@ export const CompanySelector = ({
return []; return [];
} }
const results = await axios.get<TmdbCompanySearchResponse>( const res = await fetch(
'/api/v1/search/company', `/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
); );
if (!res.ok) {
throw new Error('Network response was not ok');
}
const results: TmdbCompanySearchResponse = await res.json();
return results.data.results.map((result) => ({ return results.results.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));
@@ -154,11 +157,15 @@ export const GenreSelector = ({
const genres = defaultValue.split(','); const genres = defaultValue.split(',');
const response = await axios.get<TmdbGenre[]>(`/api/v1/genres/${type}`); const res = await fetch(`/api/v1/genres/${type}`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const response: TmdbGenre[] = await res.json();
const genreData = genres const genreData = genres
.filter((genre) => response.data.find((gd) => gd.id === Number(genre))) .filter((genre) => response.find((gd) => gd.id === Number(genre)))
.map((g) => response.data.find((gd) => gd.id === Number(g))) .map((g) => response.find((gd) => gd.id === Number(g)))
.map((g) => ({ .map((g) => ({
label: g?.name ?? '', label: g?.name ?? '',
value: g?.id ?? 0, value: g?.id ?? 0,
@@ -171,11 +178,11 @@ export const GenreSelector = ({
}, [defaultValue, type]); }, [defaultValue, type]);
const loadGenreOptions = async (inputValue: string) => { const loadGenreOptions = async (inputValue: string) => {
const results = await axios.get<GenreSliderItem[]>( const res = await fetch(`/api/v1/discover/genreslider/${type}`);
`/api/v1/discover/genreslider/${type}` if (!res.ok) throw new Error();
); const results: GenreSliderItem[] = await res.json();
return results.data return results
.map((result) => ({ .map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
@@ -204,6 +211,75 @@ export const GenreSelector = ({
); );
}; };
export const StatusSelector = ({
isMulti,
defaultValue,
onChange,
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
const intl = useIntl();
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
const options = useMemo(
() => [
{ name: intl.formatMessage(messages.returningSeries), id: 0 },
{ name: intl.formatMessage(messages.planned), id: 1 },
{ name: intl.formatMessage(messages.inProduction), id: 2 },
{ name: intl.formatMessage(messages.ended), id: 3 },
{ name: intl.formatMessage(messages.canceled), id: 4 },
{ name: intl.formatMessage(messages.pilot), id: 5 },
],
[intl]
);
useEffect(() => {
const loadDefaultStatus = async (): Promise<void> => {
if (!defaultValue) {
return;
}
const statuses = defaultValue.split('|');
const statusData = options
.filter((opt) => statuses.find((s) => Number(s) === opt.id))
.map((o) => ({
label: o.name,
value: o.id,
}));
setDefaultDataValue(statusData);
};
loadDefaultStatus();
}, [defaultValue, options]);
const loadStatusOptions = async () => {
return options
.map((result) => ({
label: result.name,
value: result.id,
}))
.filter(({ label }) => label.toLowerCase());
};
return (
<AsyncSelect
key={`status-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
defaultOptions
isMulti={isMulti}
loadOptions={loadStatusOptions}
placeholder={intl.formatMessage(messages.searchStatus)}
onChange={(value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange(value as any);
}}
/>
);
};
export const KeywordSelector = ({ export const KeywordSelector = ({
isMulti, isMulti,
defaultValue, defaultValue,
@@ -222,11 +298,13 @@ export const KeywordSelector = ({
const keywords = await Promise.all( const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => { defaultValue.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>( const res = await fetch(`/api/v1/keyword/${keywordId}`);
`/api/v1/keyword/${keywordId}` if (!res.ok) {
); throw new Error('Network response was not ok');
}
const keyword: Keyword = await res.json();
return keyword.data; return keyword;
}) })
); );
@@ -242,16 +320,15 @@ export const KeywordSelector = ({
}, [defaultValue]); }, [defaultValue]);
const loadKeywordOptions = async (inputValue: string) => { const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>( const res = await fetch(
'/api/v1/search/keyword', `/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
); );
if (!res.ok) {
throw new Error('Network response was not ok');
}
const results: TmdbKeywordSearchResponse = await res.json();
return results.data.results.map((result) => ({ return results.results.map((result) => ({
label: result.name, label: result.name,
value: result.id, value: result.id,
})); }));

View File

@@ -1,7 +1,6 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import axios from 'axios';
import { useEffect } from 'react'; import { useEffect } from 'react';
const ServiceWorkerSetup = () => { const ServiceWorkerSetup = () => {
@@ -26,11 +25,18 @@ const ServiceWorkerSetup = () => {
const parsedSub = JSON.parse(JSON.stringify(sub)); const parsedSub = JSON.parse(JSON.stringify(sub));
if (parsedSub.keys.p256dh && parsedSub.keys.auth) { if (parsedSub.keys.p256dh && parsedSub.keys.auth) {
await axios.post('/api/v1/user/registerPushSubscription', { const res = await fetch('/api/v1/user/registerPushSubscription', {
endpoint: parsedSub.endpoint, method: 'POST',
p256dh: parsedSub.keys.p256dh, headers: {
auth: parsedSub.keys.auth, 'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: parsedSub.endpoint,
p256dh: parsedSub.keys.p256dh,
auth: parsedSub.keys.auth,
}),
}); });
if (!res.ok) throw new Error();
} }
} }
}) })

View File

@@ -5,7 +5,6 @@ import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -73,16 +72,23 @@ const NotificationsDiscord = () => {
validationSchema={NotificationsDiscordSchema} validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/settings/notifications/discord', { const res = await fetch('/api/v1/settings/notifications/discord', {
enabled: values.enabled, method: 'POST',
types: values.types, headers: {
options: { 'Content-Type': 'application/json',
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
enableMentions: values.enableMentions,
}, },
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
enableMentions: values.enableMentions,
},
}),
}); });
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.discordsettingssaved), { addToast(intl.formatMessage(messages.discordsettingssaved), {
appearance: 'success', appearance: 'success',
@@ -121,16 +127,26 @@ const NotificationsDiscord = () => {
toastId = id; toastId = id;
} }
); );
await axios.post('/api/v1/settings/notifications/discord/test', { const res = await fetch(
enabled: true, '/api/v1/settings/notifications/discord/test',
types: values.types, {
options: { method: 'POST',
botUsername: values.botUsername, headers: {
botAvatarUrl: values.botAvatarUrl, 'Content-Type': 'application/json',
webhookUrl: values.webhookUrl, },
enableMentions: values.enableMentions, body: JSON.stringify({
}, enabled: true,
}); types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
enableMentions: values.enableMentions,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) { if (toastId) {
removeToast(toastId); removeToast(toastId);

View File

@@ -5,7 +5,6 @@ import SettingsBadge from '@app/components/Settings/SettingsBadge';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -148,24 +147,31 @@ const NotificationsEmail = () => {
validationSchema={NotificationsEmailSchema} validationSchema={NotificationsEmailSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/settings/notifications/email', { const res = await fetch('/api/v1/settings/notifications/email', {
enabled: values.enabled, method: 'POST',
options: { headers: {
userEmailRequired: values.userEmailRequired, 'Content-Type': 'application/json',
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
smtpPort: Number(values.smtpPort),
secure: values.encryption === 'implicit',
ignoreTls: values.encryption === 'none',
requireTls: values.encryption === 'opportunistic',
authUser: values.authUser,
authPass: values.authPass,
allowSelfSigned: values.allowSelfSigned,
senderName: values.senderName,
pgpPrivateKey: values.pgpPrivateKey,
pgpPassword: values.pgpPassword,
}, },
body: JSON.stringify({
enabled: values.enabled,
options: {
userEmailRequired: values.userEmailRequired,
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
smtpPort: Number(values.smtpPort),
secure: values.encryption === 'implicit',
ignoreTls: values.encryption === 'none',
requireTls: values.encryption === 'opportunistic',
authUser: values.authUser,
authPass: values.authPass,
allowSelfSigned: values.allowSelfSigned,
senderName: values.senderName,
pgpPrivateKey: values.pgpPrivateKey,
pgpPassword: values.pgpPassword,
},
}),
}); });
if (!res.ok) throw new Error();
mutate('/api/v1/settings/public'); mutate('/api/v1/settings/public');
addToast(intl.formatMessage(messages.emailsettingssaved), { addToast(intl.formatMessage(messages.emailsettingssaved), {
@@ -197,22 +203,32 @@ const NotificationsEmail = () => {
toastId = id; toastId = id;
} }
); );
await axios.post('/api/v1/settings/notifications/email/test', { const res = await fetch(
enabled: true, '/api/v1/settings/notifications/email/test',
options: { {
emailFrom: values.emailFrom, method: 'POST',
smtpHost: values.smtpHost, headers: {
smtpPort: Number(values.smtpPort), 'Content-Type': 'application/json',
secure: values.encryption === 'implicit', },
ignoreTls: values.encryption === 'none', body: JSON.stringify({
requireTls: values.encryption === 'opportunistic', enabled: true,
authUser: values.authUser, options: {
authPass: values.authPass, emailFrom: values.emailFrom,
senderName: values.senderName, smtpHost: values.smtpHost,
pgpPrivateKey: values.pgpPrivateKey, smtpPort: Number(values.smtpPort),
pgpPassword: values.pgpPassword, secure: values.encryption === 'implicit',
}, ignoreTls: values.encryption === 'none',
}); requireTls: values.encryption === 'opportunistic',
authUser: values.authUser,
authPass: values.authPass,
senderName: values.senderName,
pgpPrivateKey: values.pgpPrivateKey,
pgpPassword: values.pgpPassword,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) { if (toastId) {
removeToast(toastId); removeToast(toastId);

View File

@@ -4,7 +4,6 @@ import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/solid'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -83,14 +82,21 @@ const NotificationsGotify = () => {
validationSchema={NotificationsGotifySchema} validationSchema={NotificationsGotifySchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/settings/notifications/gotify', { const res = await fetch('/api/v1/settings/notifications/gotify', {
enabled: values.enabled, method: 'POST',
types: values.types, headers: {
options: { 'Content-Type': 'application/json',
url: values.url,
token: values.token,
}, },
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
url: values.url,
token: values.token,
},
}),
}); });
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.gotifysettingssaved), { addToast(intl.formatMessage(messages.gotifysettingssaved), {
appearance: 'success', appearance: 'success',
autoDismiss: true, autoDismiss: true,
@@ -128,14 +134,24 @@ const NotificationsGotify = () => {
toastId = id; toastId = id;
} }
); );
await axios.post('/api/v1/settings/notifications/gotify/test', { const res = await fetch(
enabled: true, '/api/v1/settings/notifications/gotify/test',
types: values.types, {
options: { method: 'POST',
url: values.url, headers: {
token: values.token, 'Content-Type': 'application/json',
}, },
}); body: JSON.stringify({
enabled: true,
types: values.types,
options: {
url: values.url,
token: values.token,
},
}),
}
);
if (!res.ok) throw new Error();
if (toastId) { if (toastId) {
removeToast(toastId); removeToast(toastId);

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