Compare commits

..

1 Commits

Author SHA1 Message Date
fallenbagel
ac33fe75b6 fix(jellyfin): library toggle not toggling off 2024-07-24 22:07:48 +05:00
147 changed files with 2727 additions and 8518 deletions

View File

@@ -403,51 +403,6 @@
"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"
]
},
{
"login": "M0NsTeRRR",
"name": "Ludovic Ortega",
"avatar_url": "https://avatars.githubusercontent.com/u/37785089?v=4",
"profile": "https://github.com/M0NsTeRRR",
"contributions": [
"security"
]
} }
] ]
} }

2
.github/CODEOWNERS vendored
View File

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

View File

@@ -1,6 +1,6 @@
name: 🐛 Bug Report name: 🐛 Bug Report
description: Report a problem description: Report a problem
labels: ['bug', 'awaiting triage'] labels: ['type:bug', 'awaiting-triage']
body: body:
- type: markdown - type: markdown
attributes: attributes:

View File

@@ -1,6 +1,6 @@
name: ✨ Feature Request name: ✨ Feature Request
description: Suggest an idea description: Suggest an idea
labels: ['enhancement', 'awaiting triage'] labels: ['type:enhancement', 'awaiting-triage']
body: body:
- type: markdown - type: markdown
attributes: attributes:

View File

@@ -1,431 +1,3 @@
## [2.0.1](https://github.com/fallenbagel/jellyseerr/compare/v2.0.0...v2.0.1) (2024-10-17)
### Bug Fixes
* fetch override to attach XSRF token to fix csrfProtection issue ([#1014](https://github.com/fallenbagel/jellyseerr/issues/1014)) ([4945b54](https://github.com/fallenbagel/jellyseerr/commit/4945b5429848b36fc0ee41cf0277ed79f53d8286)), closes [#1011](https://github.com/fallenbagel/jellyseerr/issues/1011)
* handle non-existent rottentomatoes rating ([#1018](https://github.com/fallenbagel/jellyseerr/issues/1018)) ([a351264](https://github.com/fallenbagel/jellyseerr/commit/a351264b878b2660ae7a6415f26d38b52015c591))
* rewrite avatarproxy and CachedImage ([#1016](https://github.com/fallenbagel/jellyseerr/issues/1016)) ([4e48fdf](https://github.com/fallenbagel/jellyseerr/commit/4e48fdf2cb9f76ae5c25073b585718650abd3288)), closes [#1012](https://github.com/fallenbagel/jellyseerr/issues/1012) [#1013](https://github.com/fallenbagel/jellyseerr/issues/1013)
* use jellyfinMediaId4k for mediaUrl4k ([#1006](https://github.com/fallenbagel/jellyseerr/issues/1006)) ([a0f80fe](https://github.com/fallenbagel/jellyseerr/commit/a0f80fe7647ef4a9025ca93407cd21ddc640fed1)), closes [#520](https://github.com/fallenbagel/jellyseerr/issues/520)
# [2.0.0](https://github.com/fallenbagel/jellyseerr/compare/v1.9.2...v2.0.0) (2024-10-15)
### Bug Fixes
* abort availability sync job if auth token invalid/connection lost ([#845](https://github.com/fallenbagel/jellyseerr/issues/845)) ([bdee340](https://github.com/fallenbagel/jellyseerr/commit/bdee34053080c8975a88ba16a9e8f402e10fe7e1))
* add an error message to say when an email is already taken ([#947](https://github.com/fallenbagel/jellyseerr/issues/947)) ([89e0a83](https://github.com/fallenbagel/jellyseerr/commit/89e0a831ec85a6905f539f59b7523bb1feb90bcf))
* add missing brackets ([#888](https://github.com/fallenbagel/jellyseerr/issues/888)) ([6cea8bb](https://github.com/fallenbagel/jellyseerr/commit/6cea8bba592b8db566b4d8147630385f5c377f1b))
* add missing content-type header ([#887](https://github.com/fallenbagel/jellyseerr/issues/887)) ([2be9c7d](https://github.com/fallenbagel/jellyseerr/commit/2be9c7dcc1f418726a19e99cfdb3933257a03c6f))
* add missing header when creating an issue ([#879](https://github.com/fallenbagel/jellyseerr/issues/879)) ([084e1b2](https://github.com/fallenbagel/jellyseerr/commit/084e1b224e109f0f8279741b9a5ead138396d7f8))
* add missing parameter to delete requests from ExternalAPI ([#904](https://github.com/fallenbagel/jellyseerr/issues/904)) ([36d98a2](https://github.com/fallenbagel/jellyseerr/commit/36d98a2681921a8770027b78878688f2782e8b77)), closes [#903](https://github.com/fallenbagel/jellyseerr/issues/903)
* **api:** fix nextjs error handler ([#882](https://github.com/fallenbagel/jellyseerr/issues/882)) ([0116c13](https://github.com/fallenbagel/jellyseerr/commit/0116c13e0632d1ccec43299fbb10cd71db45bc29))
* **api:** handle non-existent ratings on IMDb ([#822](https://github.com/fallenbagel/jellyseerr/issues/822)) ([74a2d25](https://github.com/fallenbagel/jellyseerr/commit/74a2d25f153b07a0cae5b44adca5fa1fed5a3b9e))
* **api:** save new password when reset password of local account ([#886](https://github.com/fallenbagel/jellyseerr/issues/886)) ([5cc4389](https://github.com/fallenbagel/jellyseerr/commit/5cc43898256b130c2576f34a3d4e7ce6a3940d3e))
* **blacklist:** add blacklist to mobile menu ([#980](https://github.com/fallenbagel/jellyseerr/issues/980)) ([f390da4](https://github.com/fallenbagel/jellyseerr/commit/f390da486625a22951956ba96867de63f73bfc2b)), closes [#979](https://github.com/fallenbagel/jellyseerr/issues/979)
* change SeriesSearch to MissingEpisodeSearch for season requests ([#711](https://github.com/fallenbagel/jellyseerr/issues/711)) ([ee7e91c](https://github.com/fallenbagel/jellyseerr/commit/ee7e91c7c948b17b556a625919eb1252a721bb6e))
* **docker:** add postinstall script ([#839](https://github.com/fallenbagel/jellyseerr/issues/839)) ([f714132](https://github.com/fallenbagel/jellyseerr/commit/f7141329094d88eb0940b1db1f21376142cb8893))
* enhance error messages when Fetch API fails ([#893](https://github.com/fallenbagel/jellyseerr/issues/893)) ([fccfca6](https://github.com/fallenbagel/jellyseerr/commit/fccfca6ed06c8dc599e1ea4b1b3dbac48eb3a7f6))
* handle status badge for season packs ([#927](https://github.com/fallenbagel/jellyseerr/issues/927)) ([80f6301](https://github.com/fallenbagel/jellyseerr/commit/80f63017ac5e9b1720a19c761dbef4dd517f1c2c))
* length of undefined on users warnings ([#875](https://github.com/fallenbagel/jellyseerr/issues/875)) ([c600566](https://github.com/fallenbagel/jellyseerr/commit/c600566ac0045c2314f9013b063007b087ee4327))
* remove DNS caching ([#837](https://github.com/fallenbagel/jellyseerr/issues/837)) ([268c7df](https://github.com/fallenbagel/jellyseerr/commit/268c7df28eea8b911d6a53297f5ce296983067ce))
* remove email requirement for the user, and use the username if no email provided ([#900](https://github.com/fallenbagel/jellyseerr/issues/900)) ([d5f817e](https://github.com/fallenbagel/jellyseerr/commit/d5f817e734131cdacc229361d9498a095af57950))
* remove protocol-relative URLs from next/image ([#889](https://github.com/fallenbagel/jellyseerr/issues/889)) ([c80d9a8](https://github.com/fallenbagel/jellyseerr/commit/c80d9a853a2a3451293a5382ef183c18add0c040))
* resize episode preview image ([#842](https://github.com/fallenbagel/jellyseerr/issues/842)) ([96ba53f](https://github.com/fallenbagel/jellyseerr/commit/96ba53fecc7b9d269f0d974051ab62836b0102bc))
* resize header image in network and studio pages ([#902](https://github.com/fallenbagel/jellyseerr/issues/902)) ([4220855](https://github.com/fallenbagel/jellyseerr/commit/422085523e5dfc132f3c3ca19eaa87117828b7be))
* rewrite request from axios to Fetch ([#920](https://github.com/fallenbagel/jellyseerr/issues/920)) ([9aee888](https://github.com/fallenbagel/jellyseerr/commit/9aee8887d3cca6e018f4be1c8400c22e86bf8dab))
* rewrite the rate limit utility ([#896](https://github.com/fallenbagel/jellyseerr/issues/896)) ([3fc14c9](https://github.com/fallenbagel/jellyseerr/commit/3fc14c9e2262463afec666e7f54e38d0d36cff68))
* **session:** set the correct TTL for the cookie store ([#992](https://github.com/fallenbagel/jellyseerr/issues/992)) ([96e1d40](https://github.com/fallenbagel/jellyseerr/commit/96e1d40304749ce00d2ff7359efc39a1d9724358)), closes [#991](https://github.com/fallenbagel/jellyseerr/issues/991)
* set correct user type when importing from emby ([#949](https://github.com/fallenbagel/jellyseerr/issues/949)) ([e57d265](https://github.com/fallenbagel/jellyseerr/commit/e57d2654d1c634a91649722d3a2bf4d73c4a02ca)), closes [#948](https://github.com/fallenbagel/jellyseerr/issues/948)
* **setup:** page display when homepage is loading ([#940](https://github.com/fallenbagel/jellyseerr/issues/940)) ([7423bbb](https://github.com/fallenbagel/jellyseerr/commit/7423bbbffc5bee2e52e3348254f035dc8527d973))
* **tmdb:** fallback movie/show overview to English when none is available in requested locale ([#928](https://github.com/fallenbagel/jellyseerr/issues/928)) ([12f908d](https://github.com/fallenbagel/jellyseerr/commit/12f908de7f5fbd717a5f151858b6edee3be13ed9)), closes [#925](https://github.com/fallenbagel/jellyseerr/issues/925)
* update the filter removing existing users from Jellyfin import modal ([#924](https://github.com/fallenbagel/jellyseerr/issues/924)) ([61dcd8e](https://github.com/fallenbagel/jellyseerr/commit/61dcd8e487d7886773ccb12501623c17838476e5))
### Code Refactoring
* **jellyfin:** abstract jellyfin hostname, updated ui to reflect it, better validation ([#773](https://github.com/fallenbagel/jellyseerr/issues/773)) ([38ad875](https://github.com/fallenbagel/jellyseerr/commit/38ad875dd7848b4e92ac3ccdd16dbf785f6a5c4d))
### Features
* add environment variable for API key ([#831](https://github.com/fallenbagel/jellyseerr/issues/831)) ([45ef150](https://github.com/fallenbagel/jellyseerr/commit/45ef150e36944d456cc9440574b5ac75f2e4bbc1))
* adds status filter for tv shows ([#796](https://github.com/fallenbagel/jellyseerr/issues/796)) ([cfd1bc2](https://github.com/fallenbagel/jellyseerr/commit/cfd1bc253557d6e19725743b8aa9a2fa33bbe760)), closes [#605](https://github.com/fallenbagel/jellyseerr/issues/605)
* allow request managers to delete data from sonarr/radarr ([#644](https://github.com/fallenbagel/jellyseerr/issues/644)) ([a5d22ba](https://github.com/fallenbagel/jellyseerr/commit/a5d22ba5b83dd0e812b16f06476d993b5d59cb2a))
* blacklist items from Discover page ([#632](https://github.com/fallenbagel/jellyseerr/issues/632)) ([818aa60](https://github.com/fallenbagel/jellyseerr/commit/818aa60aac185da07bfb71b08e0448939b63a736)), closes [#490](https://github.com/fallenbagel/jellyseerr/issues/490)
* Jellyfin/Emby server type setup ([#685](https://github.com/fallenbagel/jellyseerr/issues/685)) ([15cb949](https://github.com/fallenbagel/jellyseerr/commit/15cb949f1f2e617853f90ae7bb8ae5d6622f610e))
* **jellyfinapi:** switch to API tokens instead of auth tokens ([#868](https://github.com/fallenbagel/jellyseerr/issues/868)) ([bd4da6d](https://github.com/fallenbagel/jellyseerr/commit/bd4da6d5fc8cb55c2bc3d9a8336787cbd30814d0))
* Option on item's page to add/remove from watchlist ([#781](https://github.com/fallenbagel/jellyseerr/issues/781)) ([2348f23](https://github.com/fallenbagel/jellyseerr/commit/2348f23f433195d64dee3e6eeede296fca5fdbc9)), closes [#730](https://github.com/fallenbagel/jellyseerr/issues/730)
* refresh monitored downloads before getting queue items ([#994](https://github.com/fallenbagel/jellyseerr/issues/994)) ([92ba262](https://github.com/fallenbagel/jellyseerr/commit/92ba26207dcb1ddd696e0f01931d2609c521ae45)), closes [#866](https://github.com/fallenbagel/jellyseerr/issues/866)
* show quality profile on request ([#847](https://github.com/fallenbagel/jellyseerr/issues/847)) ([6445332](https://github.com/fallenbagel/jellyseerr/commit/64453320d36595e75dcb710dfd43997bf2d2acd5))
* **translation:** added full Hebrew translation ([#871](https://github.com/fallenbagel/jellyseerr/issues/871)) ([c96ca67](https://github.com/fallenbagel/jellyseerr/commit/c96ca6742e0a6d5685319c52f995fe06e439a450))
* update Plex logo ([#884](https://github.com/fallenbagel/jellyseerr/issues/884)) ([3a363ae](https://github.com/fallenbagel/jellyseerr/commit/3a363ae1ffa7f384be6f7d25f8558b1e55a73fb3))
### Reverts
* fix(api): fix nextjs error handler ([#882](https://github.com/fallenbagel/jellyseerr/issues/882)) ([#892](https://github.com/fallenbagel/jellyseerr/issues/892)) ([62dbde4](https://github.com/fallenbagel/jellyseerr/commit/62dbde448c7f7d530de8534bb8538452d0f91276))
### BREAKING CHANGES
* 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
* 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
* **jellyfin:** Jellyfin settings now does not include a hostname. Instead it abstracted it to ip,
port, useSsl, and urlBase. However, migration of old settings to new settings should work
automatically.
* refactor: remove console logs and use getHostname and ApiErrorCodes
* fix: store req.body jellyfin settings temporarily and store only if valid
This should fix the issue where settings are saved even if the url
was invalid. Now the settings will only be saved if the url is
valid. Sort of like a test connection.
* refactor: clean up commented out code
* refactor(i18n): extract translation keys
* fix(auth): auth failing with jellyfin login is disabled
* fix(settings): jellyfin migrations replacing the rest of the settings
* fix(settings): jellyfin hostname should be carried out if hostname exists
* fix(settings): merging the wrong settings source
* refactor(settings): use migrator for dynamic settings migrations
* refactor(settingsmigrator): settings migration handler and the migrations
* test(cypress): fix cypress tests failing
cypress settings were lacking some of the jobs so when the startJobs() is called when the app
starts, it was failing to schedule the jobs where their cron timings were not specified in the
cypress settings. Therefore, this commit adds those jobs back. In addition, other setting options
were added to keep cypress settings consistent with a normal user.
* chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier
* chore(prettier): ran formatter on cypress config to fix format check error
format check locally passes on this file. However, it fails during the github actions format check.
Therefore, json language features formatter was run instead of prettier to see if that fixes the
issue.
* test(cypress): add only missing jobs to the cypress settings
* ci: attempt at trying to get formatter to pass on cypress config json file
* refactor: revert the changes brought to try and fix formatter
added back the rest of the cypress settings and removed cypress settings from .prettierignore
* refactor(settings): better erorr logging when jellyfin connection test fails in settings page
## [1.9.2](https://github.com/fallenbagel/jellyseerr/compare/v1.9.1...v1.9.2) (2024-06-13)
### Bug Fixes
* **auth:** improve login resilience with headerless fallback authentication ([#814](https://github.com/fallenbagel/jellyseerr/issues/814)) ([a9741fa](https://github.com/fallenbagel/jellyseerr/commit/a9741fa36d06710aa00d28db3dd2c29f2b0973d3))
* **auth:** validation of ipv6/ipv4 ([#812](https://github.com/fallenbagel/jellyseerr/issues/812)) ([9aeb360](https://github.com/fallenbagel/jellyseerr/commit/9aeb3604e6498c388df1d30dd0b613ba84160fc0)), closes [#795](https://github.com/fallenbagel/jellyseerr/issues/795)
* bypass cache-able lookups when resolving localhost ([#813](https://github.com/fallenbagel/jellyseerr/issues/813)) ([b5a0699](https://github.com/fallenbagel/jellyseerr/commit/b5a069901a9545772deaa9c491f2075261da0189))
## [1.9.1](https://github.com/fallenbagel/jellyseerr/compare/v1.9.0...v1.9.1) (2024-06-12)
### Bug Fixes
* **api:** add DNS caching ([#810](https://github.com/fallenbagel/jellyseerr/issues/810)) ([46ee8a4](https://github.com/fallenbagel/jellyseerr/commit/46ee8a4ca13b026bd929b4027eb001cc74064bb8)), closes [#387](https://github.com/fallenbagel/jellyseerr/issues/387) [#657](https://github.com/fallenbagel/jellyseerr/issues/657) [#728](https://github.com/fallenbagel/jellyseerr/issues/728)
* empty email in user settings ([#807](https://github.com/fallenbagel/jellyseerr/issues/807)) ([20863d4](https://github.com/fallenbagel/jellyseerr/commit/20863d4a8dabe78fb5c52995b5bcb2da557a804e)), closes [#803](https://github.com/fallenbagel/jellyseerr/issues/803)
* **jellyfinscanner:** assign only 4k available badge for a 4k request instead of both badges ([#805](https://github.com/fallenbagel/jellyseerr/issues/805)) ([d31a2c3](https://github.com/fallenbagel/jellyseerr/commit/d31a2c37e639c1126b446277fa5d666d8102fef5))
* remove the settings button of media when useless ([#809](https://github.com/fallenbagel/jellyseerr/issues/809)) ([f52939e](https://github.com/fallenbagel/jellyseerr/commit/f52939e4cdcbee94fc35165f613f6b3e21599e3c))
### Reverts
* Revert "ci: update format check command to ignore .prettierignore files (#787)" (#788) ([4757f1c](https://github.com/fallenbagel/jellyseerr/commit/4757f1c3e599304410a737c11f97db92a2bfcefd)), closes [#787](https://github.com/fallenbagel/jellyseerr/issues/787) [#788](https://github.com/fallenbagel/jellyseerr/issues/788)
# [1.9.0](https://github.com/fallenbagel/jellyseerr/compare/v1.8.1...v1.9.0) (2024-05-29)
### Bug Fixes
* **api:** save user email on the first try ([#760](https://github.com/fallenbagel/jellyseerr/issues/760)) ([0bbcfdc](https://github.com/fallenbagel/jellyseerr/commit/0bbcfdc4f9ff9735f45232a2412ac8444f525de9)), closes [#227](https://github.com/fallenbagel/jellyseerr/issues/227) [#748](https://github.com/fallenbagel/jellyseerr/issues/748)
* **api:** small errors on overseerr-api.yaml ([#721](https://github.com/fallenbagel/jellyseerr/issues/721)) ([0eea109](https://github.com/fallenbagel/jellyseerr/commit/0eea1090dfdba4333646280c84b09b0197fefa74))
* **auth:** case-sensitive logins not updating authtokens ([#778](https://github.com/fallenbagel/jellyseerr/issues/778)) ([2bd125d](https://github.com/fallenbagel/jellyseerr/commit/2bd125d9a55d15a398ceb5f2996105a5e861b6e0))
* **jellyfinapi:** use external api class for jellyfin api requests ([#762](https://github.com/fallenbagel/jellyseerr/issues/762)) ([650c339](https://github.com/fallenbagel/jellyseerr/commit/650c339d74d4fe85ef7f76184901e86f4eeada85)), closes [#728](https://github.com/fallenbagel/jellyseerr/issues/728) [#387](https://github.com/fallenbagel/jellyseerr/issues/387)
* **logging:** handle media server connection refused error/toast ([#748](https://github.com/fallenbagel/jellyseerr/issues/748)) ([f486fb5](https://github.com/fallenbagel/jellyseerr/commit/f486fb5e75f9ea21456952b6a52cb841e30f3556))
* use UTF8 encoding for webhook JSON ([#714](https://github.com/fallenbagel/jellyseerr/issues/714)) ([c0a0b9c](https://github.com/fallenbagel/jellyseerr/commit/c0a0b9c8a8b0c2eeaf3fa9159f10742baa9f6c1f))
### Features
* add Latin American Spanish translation ([#725](https://github.com/fallenbagel/jellyseerr/issues/725)) ([783fda9](https://github.com/fallenbagel/jellyseerr/commit/783fda9621aef8ffd46e5f036136de82ed502ccc)), closes [#677](https://github.com/fallenbagel/jellyseerr/issues/677)
* add merge conflict labeler workflow ([#719](https://github.com/fallenbagel/jellyseerr/issues/719)) ([d9d07c7](https://github.com/fallenbagel/jellyseerr/commit/d9d07c705a24d5c49905066aac45a3c6a2e36a53))
* **auth:** send real information on login ([#470](https://github.com/fallenbagel/jellyseerr/issues/470)) ([d765055](https://github.com/fallenbagel/jellyseerr/commit/d765055da83ee94546399f6348aee14d8427d462))
* **settings:** stores jellyfin/emby server name in the settings ([#763](https://github.com/fallenbagel/jellyseerr/issues/763)) ([7a5e8d6](https://github.com/fallenbagel/jellyseerr/commit/7a5e8d69bf620c8e7bf5f284840b1a5fe757ae5f))
## [1.8.1](https://github.com/fallenbagel/jellyseerr/compare/v1.8.0...v1.8.1) (2024-04-17)
### Reverts
* Revert "fix: disable seasonfolder option in sonarr for jellyfin/Emby users" (#718) ([cd0fa3e](https://github.com/fallenbagel/jellyseerr/commit/cd0fa3e2232dcb522673143f113fc382fb2ff0a3)), closes [#718](https://github.com/fallenbagel/jellyseerr/issues/718)
# [1.8.0](https://github.com/fallenbagel/jellyseerr/compare/v1.7.0...v1.8.0) (2024-04-15)
### Bug Fixes
* correct width issue in datepicker of filterSliderOver ([f564cdd](https://github.com/fallenbagel/jellyseerr/commit/f564cddff4525ccebffbf304672d49c57aefe635)), closes [#415](https://github.com/fallenbagel/jellyseerr/issues/415)
* disable seasonfolder option in sonarr for jellyfin/Emby users ([8ec8f2a](https://github.com/fallenbagel/jellyseerr/commit/8ec8f2ac5730aad3b12dcd8ed95bb553b46b399c)), closes [#126](https://github.com/fallenbagel/jellyseerr/issues/126) [#575](https://github.com/fallenbagel/jellyseerr/issues/575)
* **embyauth:** remove the accidentally added mediaServerType change code from another PR ([#684](https://github.com/fallenbagel/jellyseerr/issues/684)) ([c2e8771](https://github.com/fallenbagel/jellyseerr/commit/c2e87714b4c4aa11bf68dcd82b76979f82990f3c))
* ensure watchlist updates are immediately reflected ([b85d7f3](https://github.com/fallenbagel/jellyseerr/commit/b85d7f37b931735ca2ad955dccb6599bf445fc73))
* fix german translation for "components.Discover.FilterSlideover.tmdbuservotecount" ([e032c02](https://github.com/fallenbagel/jellyseerr/commit/e032c02f5f84dc4b6b470eecb18ba2c376c55f37))
* fix the translations for watchlist permissions and userSettings page ([8c82a61](https://github.com/fallenbagel/jellyseerr/commit/8c82a61450a7525c0e2f1b64e6939da47a7c715d))
* **i18n:** fixed jellyfin jobs ([7eed236](https://github.com/fallenbagel/jellyseerr/commit/7eed23637ddfb10bdcb19698e7ae171f07299502))
* **jellyfin.ts:** process virtual seasons if they have non virtual episodes ([#639](https://github.com/fallenbagel/jellyseerr/issues/639)) ([db84f65](https://github.com/fallenbagel/jellyseerr/commit/db84f6529ab285be26c96daaab065dfabf347417))
* **jellyfinapi:** refactors jellyfin library sync to support automatic grouping and collections ([#700](https://github.com/fallenbagel/jellyseerr/issues/700)) ([3856061](https://github.com/fallenbagel/jellyseerr/commit/3856061fe1ee4d3457996586b4979ad9dd60765a)), closes [#450](https://github.com/fallenbagel/jellyseerr/issues/450) [#524](https://github.com/fallenbagel/jellyseerr/issues/524) [#256](https://github.com/fallenbagel/jellyseerr/issues/256) [#489](https://github.com/fallenbagel/jellyseerr/issues/489) [#450](https://github.com/fallenbagel/jellyseerr/issues/450) [#524](https://github.com/fallenbagel/jellyseerr/issues/524) [#515](https://github.com/fallenbagel/jellyseerr/issues/515) [#474](https://github.com/fallenbagel/jellyseerr/issues/474) [#473](https://github.com/fallenbagel/jellyseerr/issues/473)
* **jellyfinlogin:** use externalHostname if set for forgetpassword link ([405f6bb](https://github.com/fallenbagel/jellyseerr/commit/405f6bbb7ffc390327c99dcef2cbbf9b3bc75f01)), closes [#199](https://github.com/fallenbagel/jellyseerr/issues/199) [#424](https://github.com/fallenbagel/jellyseerr/issues/424) [#212](https://github.com/fallenbagel/jellyseerr/issues/212)
* **jellyfinscanner:** conditionally assign the jellyfinMediaId and jellyfinMediaId4k ([#686](https://github.com/fallenbagel/jellyseerr/issues/686)) ([530be42](https://github.com/fallenbagel/jellyseerr/commit/530be4272cce1b0d74d7f4156b8d794cda6ea03f)), closes [#681](https://github.com/fallenbagel/jellyseerr/issues/681)
* **langcode:** fixes the ukranian language code ([dc67aaa](https://github.com/fallenbagel/jellyseerr/commit/dc67aaaf53eae86ba20c6c2798c92ec40962d85f)), closes [#504](https://github.com/fallenbagel/jellyseerr/issues/504)
* nullable type for jellyfinMediaId(4k) ([#702](https://github.com/fallenbagel/jellyseerr/issues/702)) ([0900a95](https://github.com/fallenbagel/jellyseerr/commit/0900a95532501b6f4d9698de7530a771512924fc)), closes [#668](https://github.com/fallenbagel/jellyseerr/issues/668)
* request watchlist items sequentially to prevent bypassing quota ([#3667](https://github.com/fallenbagel/jellyseerr/issues/3667)) ([b40ba07](https://github.com/fallenbagel/jellyseerr/commit/b40ba07a4de5857b8392f667038eeb0b22aa5d9a))
* resolved issue with region selector and all regions value ([#3652](https://github.com/fallenbagel/jellyseerr/issues/3652)) ([28a2c50](https://github.com/fallenbagel/jellyseerr/commit/28a2c50495d0ce531da7f8c442bd488a54b1e84c))
* typos on readme ([#655](https://github.com/fallenbagel/jellyseerr/issues/655)) ([eee9a02](https://github.com/fallenbagel/jellyseerr/commit/eee9a025d246c72bcd3aca753d9e49c1f8f064ea))
* **watchlist:** added missing prop for watchlist item removal button in watchlist page ([a0ec992](https://github.com/fallenbagel/jellyseerr/commit/a0ec992028093257e9fa043622e236014f02dea3))
* **watchlist:** discover local watchlist item display and profile local watchlist slider visibility ([3cb9494](https://github.com/fallenbagel/jellyseerr/commit/3cb9494e6210151716587d8c4b22e0a21692cf88))
### Features
* add ko language ([#3619](https://github.com/fallenbagel/jellyseerr/issues/3619)) ([9250735](https://github.com/fallenbagel/jellyseerr/commit/92507359b48db08b0066047d6505660b8c8b0b12))
* add Peacock to Network Slider ([#3545](https://github.com/fallenbagel/jellyseerr/issues/3545)) ([0c39057](https://github.com/fallenbagel/jellyseerr/commit/0c39057ca58743697e9dcc3b678440ac3688c65a))
* add tooltips to tautulli avatars ([#3601](https://github.com/fallenbagel/jellyseerr/issues/3601)) ([c484810](https://github.com/fallenbagel/jellyseerr/commit/c484810f965f8d04643c25c6d283dd83f4bd4a23))
* added Letterboxd links for the external link blocks for movies ([981f5e6](https://github.com/fallenbagel/jellyseerr/commit/981f5e679c4c707e119741240a58de8bb07f9d6c))
* check if first jellyfin user is admin ([#635](https://github.com/fallenbagel/jellyseerr/issues/635)) ([010df62](https://github.com/fallenbagel/jellyseerr/commit/010df62776191fe4c195e590df338f8d8523f55b)), closes [#610](https://github.com/fallenbagel/jellyseerr/issues/610)
* jellyseerr makeover ([#715](https://github.com/fallenbagel/jellyseerr/issues/715)) ([0c27132](https://github.com/fallenbagel/jellyseerr/commit/0c2713213c56de342f76300d12ce01fd543d2ce3))
* **job:** media availability support for jellyfin/emby ([#522](https://github.com/fallenbagel/jellyseerr/issues/522)) ([3eb1bb3](https://github.com/fallenbagel/jellyseerr/commit/3eb1bb3d8ff22391acb2e629bbec7b6e4b65ca95)), closes [#406](https://github.com/fallenbagel/jellyseerr/issues/406) [#193](https://github.com/fallenbagel/jellyseerr/issues/193) [#516](https://github.com/fallenbagel/jellyseerr/issues/516) [#362](https://github.com/fallenbagel/jellyseerr/issues/362) [#84](https://github.com/fallenbagel/jellyseerr/issues/84)
* **notif:** add Pushover sound options ([#2403](https://github.com/fallenbagel/jellyseerr/issues/2403)) ([3ea5076](https://github.com/fallenbagel/jellyseerr/commit/3ea5076053359b518b1b4d537e7b61580d9275a3))
* select default seriesType for anime ([#3627](https://github.com/fallenbagel/jellyseerr/issues/3627)) ([f628635](https://github.com/fallenbagel/jellyseerr/commit/f6286359cfd2ed93fc692aa2efda37310e02c11c)), closes [#3626](https://github.com/fallenbagel/jellyseerr/issues/3626)
* standard series type selector ([#3628](https://github.com/fallenbagel/jellyseerr/issues/3628)) ([7bdd25e](https://github.com/fallenbagel/jellyseerr/commit/7bdd25e5a45843a3e530d3fa2b0887664b53eec8))
* translations update from Hosted Weblate ([#3258](https://github.com/fallenbagel/jellyseerr/issues/3258)) ([e62a078](https://github.com/fallenbagel/jellyseerr/commit/e62a078298ced7dec627fb3ff9fc8f99a39d5e1b))
* update SameSite policy of session cookie to Lax ([#3650](https://github.com/fallenbagel/jellyseerr/issues/3650)) ([c84ca43](https://github.com/fallenbagel/jellyseerr/commit/c84ca4307465af4278f3dad5cf9c2b8cbae3fada))
### Reverts
* **jellyfinapi:** reverts [#450](https://github.com/fallenbagel/jellyseerr/issues/450) as it broke library sync support for local accounts using LDAP ([b5acc09](https://github.com/fallenbagel/jellyseerr/commit/b5acc09ba98e2dd9b61e6b78721e4dd9f42a996c)), closes [#489](https://github.com/fallenbagel/jellyseerr/issues/489)
# [1.7.0](https://github.com/fallenbagel/jellyseerr/compare/v1.6.0...v1.7.0) (2023-09-14)
### Bug Fixes
* adjust the plex watchlist sync schedule to have fuzziness ([#3502](https://github.com/fallenbagel/jellyseerr/issues/3502)) ([2c3f533](https://github.com/fallenbagel/jellyseerr/commit/2c3f5330764492e1323afd2d1f25e28ad78a2f2f))
* handle issue causing incorrect media to change to unknown ([#3516](https://github.com/fallenbagel/jellyseerr/issues/3516)) ([83b008c](https://github.com/fallenbagel/jellyseerr/commit/83b008c8391459bd02dc74bcdb0d8caf27207bdf))
* improved handling of edge case that could cause availability sync to fail ([#3497](https://github.com/fallenbagel/jellyseerr/issues/3497)) ([d0836ce](https://github.com/fallenbagel/jellyseerr/commit/d0836ce0efd55fccf2546087a0c4f94f7cb2e82a))
* Include all defaults in payload ([#3538](https://github.com/fallenbagel/jellyseerr/issues/3538)) ([cb63bf2](https://github.com/fallenbagel/jellyseerr/commit/cb63bf217b9e8810a5210b4bf475b2a96583cc84))
* multiple notifications for available media ([048fa96](https://github.com/fallenbagel/jellyseerr/commit/048fa967f2e5b23831ac9917c703934c50ef75f0))
* repeat notifications for available 4k media ([30361f2](https://github.com/fallenbagel/jellyseerr/commit/30361f2ab751d9a882a9120e0f3df28dc42cc2cd))
* resolved issue with create slider causing incorrect form submission ([#3514](https://github.com/fallenbagel/jellyseerr/issues/3514)) ([a761b7d](https://github.com/fallenbagel/jellyseerr/commit/a761b7dd35a5bd61bb4eb0275b75d1e0977e6a2d))
* resolved user access check issue ([#3551](https://github.com/fallenbagel/jellyseerr/issues/3551)) ([2816c66](https://github.com/fallenbagel/jellyseerr/commit/2816c66300bf870d493c0665b0e984d60f707dfd))
* **server/api/jellyfin.ts:** use /Library/VirtualFolders Jellyfin API call to fetch Jellyfin libs ([8685f57](https://github.com/fallenbagel/jellyseerr/commit/8685f5796a99d9700146bae9892319db10508d68)), closes [#256](https://github.com/fallenbagel/jellyseerr/issues/256)
* **statusbadge:** handle missing season/episode number ([#3526](https://github.com/fallenbagel/jellyseerr/issues/3526)) ([01de972](https://github.com/fallenbagel/jellyseerr/commit/01de972a8fe2ea3c18d5b2f426d01b5b14d142d4))
* **tautulli:** only test connection if hostname is defined ([#3573](https://github.com/fallenbagel/jellyseerr/issues/3573)) ([f7b4dfc](https://github.com/fallenbagel/jellyseerr/commit/f7b4dfcac472d08c54779a14fc1ad3c90927df26))
* **ui:** corrected issues icon color ([#3498](https://github.com/fallenbagel/jellyseerr/issues/3498)) ([c1a47bd](https://github.com/fallenbagel/jellyseerr/commit/c1a47bd9de332cb4925974690f5a33448b5cc2e6))
### Features
* **rating:** added IMDB Radarr proxy ([#3496](https://github.com/fallenbagel/jellyseerr/issues/3496)) ([b4191f9](https://github.com/fallenbagel/jellyseerr/commit/b4191f9c65b7ff08764e61d18e7a75bc8d4b3325))
# [1.6.0](https://github.com/fallenbagel/jellyseerr/compare/v1.5.0...v1.6.0) (2023-08-04)
### Bug Fixes
* availability sync file detection ([#3371](https://github.com/fallenbagel/jellyseerr/issues/3371)) ([7522aa3](https://github.com/fallenbagel/jellyseerr/commit/7522aa31743b169c903ebdf9d4d698645d27514c))
* corrected initial fallback data load on details page ([#3395](https://github.com/fallenbagel/jellyseerr/issues/3395)) ([4bd8764](https://github.com/fallenbagel/jellyseerr/commit/4bd87647d0551c20e13589a62690a6f3e5ad8ff7))
* correctly load series fallback modal with sonarr v4 ([#3451](https://github.com/fallenbagel/jellyseerr/issues/3451)) ([e051b1d](https://github.com/fallenbagel/jellyseerr/commit/e051b1dfea9c9320cc9dd420c475ae74cff0d901))
* **deps:** update all non-major dependencies ([#3223](https://github.com/fallenbagel/jellyseerr/issues/3223)) ([f5191ad](https://github.com/fallenbagel/jellyseerr/commit/f5191aded680357522a65bbdcc40d162b8fbf594))
* error deleting users with over 1000 requests ([#3376](https://github.com/fallenbagel/jellyseerr/issues/3376)) ([ac77b03](https://github.com/fallenbagel/jellyseerr/commit/ac77b037d5fb0c54f5edf4b29d04adb57aef388f))
* external url regex is now consistent with internal url ([33ec443](https://github.com/fallenbagel/jellyseerr/commit/33ec4436fb82e1eb1bc97dd650088c27785e9d94))
* externalLinkBlock ([46cd4d0](https://github.com/fallenbagel/jellyseerr/commit/46cd4d01d9a3cf17d79350c5e678202820272299))
* fix regex for internal url to use a more effecient one ([e848386](https://github.com/fallenbagel/jellyseerr/commit/e848386d10f05f157e7a6dde8847ecab50c169ac))
* fixes RT ratings for tv shows ([#3492](https://github.com/fallenbagel/jellyseerr/issues/3492)) ([04fbd00](https://github.com/fallenbagel/jellyseerr/commit/04fbd00d4ac29045592588ef8b664d1916991e37)), closes [#3491](https://github.com/fallenbagel/jellyseerr/issues/3491)
* **genreselector:** fix searching in Genre filter ([#3468](https://github.com/fallenbagel/jellyseerr/issues/3468)) ([d7fa35e](https://github.com/fallenbagel/jellyseerr/commit/d7fa35e066cf371797aaa46ca464aa531ba8fb35))
* handle search results with collections ([#3393](https://github.com/fallenbagel/jellyseerr/issues/3393)) ([70b1540](https://github.com/fallenbagel/jellyseerr/commit/70b1540ae23e83e01013856a9e06ad39e600922d))
* lock body scroll when using webkit ([#3399](https://github.com/fallenbagel/jellyseerr/issues/3399)) ([c27f960](https://github.com/fallenbagel/jellyseerr/commit/c27f96096ac8cc6c387f9d1dde5b263576ac2132))
* **logs:** jellyfin auth error now has the severity warn consistent with local login ([cc041b5](https://github.com/fallenbagel/jellyseerr/commit/cc041b5e0aa2b67573edba5919772b77a5111162)), closes [#224](https://github.com/fallenbagel/jellyseerr/issues/224)
* make a (shallow) copy of radarr/sonarr tags into a request before adding user tags ([#3485](https://github.com/fallenbagel/jellyseerr/issues/3485)) ([48f7666](https://github.com/fallenbagel/jellyseerr/commit/48f76662d5c08156f1da3f47e216c5f02668f64b))
* **ui:** corrected default badge hover opacity ([#3369](https://github.com/fallenbagel/jellyseerr/issues/3369)) ([a4d07f5](https://github.com/fallenbagel/jellyseerr/commit/a4d07f5afab613317d96c9c6e9b47157a5a28986))
* **ui:** corrected mobile menu spacing in collection details ([#3432](https://github.com/fallenbagel/jellyseerr/issues/3432)) ([77a33cb](https://github.com/fallenbagel/jellyseerr/commit/77a33cb74d744bb747b791785799b632af8c7862))
* **ui:** Make play symbol white ([1fe4bb8](https://github.com/fallenbagel/jellyseerr/commit/1fe4bb8a0415a72791ced75a2fba1027287398d5))
* **ui:** Resize Emby icon and add margins ([ad69d67](https://github.com/fallenbagel/jellyseerr/commit/ad69d6715e976630092bfbbb1843886523551014))
* **watchlist:** add validation for creation request ([03316c6](https://github.com/fallenbagel/jellyseerr/commit/03316c642d1ecf89753789af08caf6e3aac80113))
* **watchlist:** fix github code scanning ([c08897b](https://github.com/fallenbagel/jellyseerr/commit/c08897bdc1cff65862c62347572bbbd01b6c36ac))
### Features
* **add watchlist:** adding midding functionality from overserr ([5f1c10d](https://github.com/fallenbagel/jellyseerr/commit/5f1c10d50aaa430bcda96218ef2cc12a0eb926f3))
* adds streaming services custom slider ([#3361](https://github.com/fallenbagel/jellyseerr/issues/3361)) ([2520d8f](https://github.com/fallenbagel/jellyseerr/commit/2520d8f739abfde608f3ef66a9fbe6b7b5c6647a))
* auto tagging requested media with username ([#3338](https://github.com/fallenbagel/jellyseerr/issues/3338)) ([24f268b](https://github.com/fallenbagel/jellyseerr/commit/24f268b6cb67d9a8d8675cd6e09dd83a7f499add))
* **discover:** support filtering by tmdb user vote count on discover page ([#3407](https://github.com/fallenbagel/jellyseerr/issues/3407)) ([aa84977](https://github.com/fallenbagel/jellyseerr/commit/aa849776809dfe891e67ff4db6861ef44df1a774))
* **settings:** add internal url to jellyfin settings form ([0a30cd3](https://github.com/fallenbagel/jellyseerr/commit/0a30cd356d217a39546c016cc8bfa6ff6ad75e3e)), closes [#194](https://github.com/fallenbagel/jellyseerr/issues/194)
* **src/components/externallinkblock/index.tsx:** support Emby icon ([672061c](https://github.com/fallenbagel/jellyseerr/commit/672061cd646c97c9954790c8e50eac88ea2666e9))
* **tooltip:** email tooltip now appears when hovered over info icon ([cd7930e](https://github.com/fallenbagel/jellyseerr/commit/cd7930eef98451a781e5c9dc5ec223600a379f42))
* translations update ([47287c3](https://github.com/fallenbagel/jellyseerr/commit/47287c368885d14bd1a56e3e8318ce22dd0f6ddf)), closes [#381](https://github.com/fallenbagel/jellyseerr/issues/381)
* **watchlist:** add translation for en ([b7e3d28](https://github.com/fallenbagel/jellyseerr/commit/b7e3d285ed35b623062eceb0d99035cafbf075a6))
# [1.5.0](https://github.com/fallenbagel/jellyseerr/compare/v1.4.1...v1.5.0) (2023-04-20)
### Bug Fixes
* add better checks on 4k detection of series ([bc9017f](https://github.com/fallenbagel/jellyseerr/commit/bc9017f54d84ec24c4d74d38e1b4e24219425d41))
* added a refresh interval if download status is in progress ([#3275](https://github.com/fallenbagel/jellyseerr/issues/3275)) ([1e2c6f4](https://github.com/fallenbagel/jellyseerr/commit/1e2c6f46ab66c836f321b5d8e34f1e8124c0b542))
* **build:** increase threshold for amount of data to be fetched when SSR'ing ([#3320](https://github.com/fallenbagel/jellyseerr/issues/3320)) ([d7b83d2](https://github.com/fallenbagel/jellyseerr/commit/d7b83d22cee3d20db564cc0564d42802b02327e3))
* disable availability sync temporarily ([2e5cf22](https://github.com/fallenbagel/jellyseerr/commit/2e5cf226265686012329248e7f729fec324c3deb))
* hide remove button when default service is not configured ([7d4455b](https://github.com/fallenbagel/jellyseerr/commit/7d4455ba6bfd12e2730f7085cbb87df246f01d22))
* **jellyfin scan:** temporary workaround fix for jellyfin scan when display specials within season ([38fb66d](https://github.com/fallenbagel/jellyseerr/commit/38fb66d31e41232c01898d0d362af8338eb7b960)), closes [#215](https://github.com/fallenbagel/jellyseerr/issues/215) [#176](https://github.com/fallenbagel/jellyseerr/issues/176) [#246](https://github.com/fallenbagel/jellyseerr/issues/246)
* lint issues ([bcd2bb7](https://github.com/fallenbagel/jellyseerr/commit/bcd2bb7c96810f5a6932f42468a628d2db1bc771))
* logger was set to info for the wrong logs ([#3354](https://github.com/fallenbagel/jellyseerr/issues/3354)) ([c36a4ba](https://github.com/fallenbagel/jellyseerr/commit/c36a4ba2b8df05873f5dfd0946a9bc3dc4ecfd1d))
* remove unnecessary parenthesis from api key generation ([#3336](https://github.com/fallenbagel/jellyseerr/issues/3336)) ([6bd3f01](https://github.com/fallenbagel/jellyseerr/commit/6bd3f015d65507efca60279007bd2b86ee860643))
* **snapcraft:** use the correct config folder for image cache ([#3302](https://github.com/fallenbagel/jellyseerr/issues/3302)) ([c93467b](https://github.com/fallenbagel/jellyseerr/commit/c93467b3acf2c256324297e7e8f21e9944005dd4))
* **ui:** hide mini status badge if non-4K media status is unknown ([#3346](https://github.com/fallenbagel/jellyseerr/issues/3346)) ([50f06da](https://github.com/fallenbagel/jellyseerr/commit/50f06dabbffc693f0843584a64d1d96e77982820))
* **ui:** hide search bar behind slideover when opened ([#3348](https://github.com/fallenbagel/jellyseerr/issues/3348)) ([b3882de](https://github.com/fallenbagel/jellyseerr/commit/b3882de8930a70adb2f93a27be6370bfa1826587))
* **ui:** prevent title cards from flickering when quickly hovering across them ([#3349](https://github.com/fallenbagel/jellyseerr/issues/3349)) ([eb5502a](https://github.com/fallenbagel/jellyseerr/commit/eb5502a16f86e37a933f6beca0678c2d228e77d5))
* **watchlist:** correctly load more than 20 watchlist items ([#3351](https://github.com/fallenbagel/jellyseerr/issues/3351)) ([af880a6](https://github.com/fallenbagel/jellyseerr/commit/af880a6c839794b34bddcd7e0fe56353aa48ba36))
### Features
* add a button in ManageSlideOver to remove the movie and the file from Radarr/Sonarr ([2e74584](https://github.com/fallenbagel/jellyseerr/commit/2e7458457e995dd3ec6dd96035fe997646cdd446))
* availability sync rework ([#3219](https://github.com/fallenbagel/jellyseerr/issues/3219)) ([ae38183](https://github.com/fallenbagel/jellyseerr/commit/ae3818304b2f75222d1bd223ece94f829a3b42d0)), closes [#377](https://github.com/fallenbagel/jellyseerr/issues/377)
* full title of download item on hover with tooltip ([#3296](https://github.com/fallenbagel/jellyseerr/issues/3296)) ([33e7691](https://github.com/fallenbagel/jellyseerr/commit/33e7691b94d7d369a0a1410e434850bc51e5572e))
### Performance Improvements
* **imageproxy:** do not set cookies to image proxy so CDNs can cache images ([#3332](https://github.com/fallenbagel/jellyseerr/issues/3332)) ([966639d](https://github.com/fallenbagel/jellyseerr/commit/966639df430d32f6bfebdb16314dc4590d21caf8))
## [1.4.1](https://github.com/fallenbagel/jellyseerr/compare/v1.4.0...v1.4.1) (2023-01-31)
### Bug Fixes
* pass in library type when scanning recently added items ([#3287](https://github.com/fallenbagel/jellyseerr/issues/3287)) ([8942eb8](https://github.com/fallenbagel/jellyseerr/commit/8942eb8b7c4fa1d16aa2e72e8ba7120a653c9aa2))
* **ui:** air date will use UTC for timezone ([#3297](https://github.com/fallenbagel/jellyseerr/issues/3297)) ([3e43586](https://github.com/fallenbagel/jellyseerr/commit/3e43586acc0804c3fff524509caa890a104e132b))
* **ui:** correct range slider styling in chrome ([#3299](https://github.com/fallenbagel/jellyseerr/issues/3299)) ([d954328](https://github.com/fallenbagel/jellyseerr/commit/d9543289111d72245564d25d300a71b0ea3954ba))
* **ui:** show 5 icons when possible on mobile menu ([#3298](https://github.com/fallenbagel/jellyseerr/issues/3298)) ([7040da1](https://github.com/fallenbagel/jellyseerr/commit/7040da1334f6d18e19a494c73caa17f7df552dfe))
* **ui:** style range thumbs correctly for firefox ([#3294](https://github.com/fallenbagel/jellyseerr/issues/3294)) ([9d10e6a](https://github.com/fallenbagel/jellyseerr/commit/9d10e6a88c0996671f1d9d20792e1930dbc82329))
# [1.4.0](https://github.com/fallenbagel/jellyseerr/compare/v1.3.0...v1.4.0) (2023-01-29)
### Bug Fixes
* add bg-opacity to in-progress status badges ([#3190](https://github.com/fallenbagel/jellyseerr/issues/3190)) ([68223f4](https://github.com/fallenbagel/jellyseerr/commit/68223f4b1e98b01825516dcba39cbb2d3df31a70))
* added download status and title to request card/item error components ([#3186](https://github.com/fallenbagel/jellyseerr/issues/3186)) ([3309f77](https://github.com/fallenbagel/jellyseerr/commit/3309f77aa4be1d70b27693531c119a8e26822518))
* arrow icons were misplaced on mobile in slider edit ([#3260](https://github.com/fallenbagel/jellyseerr/issues/3260)) ([d328485](https://github.com/fallenbagel/jellyseerr/commit/d328485161b9cae6a70ef0713b4878207bc6015e))
* **build:** update usage of publish snap action ([#3272](https://github.com/fallenbagel/jellyseerr/issues/3272)) ([51b05cd](https://github.com/fallenbagel/jellyseerr/commit/51b05cd8fbb5d332807d8c00b2ffb7b10c3d0179))
* changed overflow scroll to only if necessary ([#3184](https://github.com/fallenbagel/jellyseerr/issues/3184)) ([27feeea](https://github.com/fallenbagel/jellyseerr/commit/27feeea69121336557deda1f32b65a5daa146f82))
* convert genre/studio to string in create slider ([#3201](https://github.com/fallenbagel/jellyseerr/issues/3201)) ([93afead](https://github.com/fallenbagel/jellyseerr/commit/93afead92e497f2e5bce67a34fffdaa08d20c7f2))
* correct checkbox position (again) for slider edits ([#3227](https://github.com/fallenbagel/jellyseerr/issues/3227)) ([3ba6df1](https://github.com/fallenbagel/jellyseerr/commit/3ba6df1a41c084c4a6a90354338047623abef521))
* correct grid sizing for webkit on streaming services ([#3248](https://github.com/fallenbagel/jellyseerr/issues/3248)) ([6fd11cf](https://github.com/fallenbagel/jellyseerr/commit/6fd11cf4254e1a19310592bec78a6de52bc073a8))
* correct issue detail bottom padding on mobile displays ([#3268](https://github.com/fallenbagel/jellyseerr/issues/3268)) ([3db010b](https://github.com/fallenbagel/jellyseerr/commit/3db010b9eaec62aa08d973a61caf1801471bbf3e))
* correct link to correct keyword results for series ([#3208](https://github.com/fallenbagel/jellyseerr/issues/3208)) ([4e9be7a](https://github.com/fallenbagel/jellyseerr/commit/4e9be7a3f7304ee7be5ee6fd34b1ea8f6c0cf399))
* correct spacing between sliders ([#3225](https://github.com/fallenbagel/jellyseerr/issues/3225)) ([62e2de7](https://github.com/fallenbagel/jellyseerr/commit/62e2de70bf37b72d5f63370b662d4103a642775b))
* correctly check mobile menu permissions ([#3271](https://github.com/fallenbagel/jellyseerr/issues/3271)) ([f4a22dc](https://github.com/fallenbagel/jellyseerr/commit/f4a22dc437404558f301ccfc195cf0a300dd1ff2))
* correctly restore selected streaming service filters ([#3249](https://github.com/fallenbagel/jellyseerr/issues/3249)) ([154f3e7](https://github.com/fallenbagel/jellyseerr/commit/154f3e72efbf0b663358b3029156f54516f01a2f))
* create shared class to add bottom spacing ([#3269](https://github.com/fallenbagel/jellyseerr/issues/3269)) ([5d1c6f7](https://github.com/fallenbagel/jellyseerr/commit/5d1c6f706555613d97ed9e61d8b665543c2f239b))
* **deps:** pin dependency @headlessui/react to 1.7.7 ([#3194](https://github.com/fallenbagel/jellyseerr/issues/3194)) [skip ci] ([c4b16ab](https://github.com/fallenbagel/jellyseerr/commit/c4b16abc62647c74215155942a4230a31a238677))
* **deps:** update dependency @heroicons/react to v2 ([#2970](https://github.com/fallenbagel/jellyseerr/issues/2970)) ([dd48d59](https://github.com/fallenbagel/jellyseerr/commit/dd48d59b20e2d1800ea30912116f4a4f1bb7928f))
* **deps:** update dependency axios to v1 ([#3202](https://github.com/fallenbagel/jellyseerr/issues/3202)) ([421029e](https://github.com/fallenbagel/jellyseerr/commit/421029ebab66c9a6622ba47e56d7f6473524cce4))
* **deps:** update dependency swr to v2 ([#3212](https://github.com/fallenbagel/jellyseerr/issues/3212)) ([7b6db50](https://github.com/fallenbagel/jellyseerr/commit/7b6db50ae55b1fc60d19a5cff62dd46bb989fa51))
* **experimental:** use new RT API (sorta) ([#3179](https://github.com/fallenbagel/jellyseerr/issues/3179)) ([357cab8](https://github.com/fallenbagel/jellyseerr/commit/357cab87ac7752b8e119b51c938b343c661d83c2))
* improve small screen layout for discover editing ([#3221](https://github.com/fallenbagel/jellyseerr/issues/3221)) ([d23b213](https://github.com/fallenbagel/jellyseerr/commit/d23b2132de05f072f7f9daad83d81421d747cf99))
* include new package calendar css in build ([#3235](https://github.com/fallenbagel/jellyseerr/issues/3235)) ([c2a1a20](https://github.com/fallenbagel/jellyseerr/commit/c2a1a20a3bb20039a1936c7fe0ecb9e8311a0aea))
* issues with issues ([#3267](https://github.com/fallenbagel/jellyseerr/issues/3267)) ([fd21971](https://github.com/fallenbagel/jellyseerr/commit/fd219717c01c558814d7a80de6304272b5a7944e))
* multiple genre filtering now works ([#3282](https://github.com/fallenbagel/jellyseerr/issues/3282)) ([5076938](https://github.com/fallenbagel/jellyseerr/commit/507693881b939819413f0959df5ef6b7a357eb5c))
* prevent double encode if we are on /search endpoint ([#3238](https://github.com/fallenbagel/jellyseerr/issues/3238)) ([a343f8a](https://github.com/fallenbagel/jellyseerr/commit/a343f8ad915491a9c81512c7e541a1dac8906025))
* **request:** approve request when retrying request ([#3234](https://github.com/fallenbagel/jellyseerr/issues/3234)) ([b515701](https://github.com/fallenbagel/jellyseerr/commit/b5157010c46cd9083993d5ee0172007b83d631da))
* **request:** mark request as approved if media is already available when retrying failed request ([#3244](https://github.com/fallenbagel/jellyseerr/issues/3244)) ([cb65074](https://github.com/fallenbagel/jellyseerr/commit/cb650745f6a33e69391a633e6d272831f314e098))
* restore border to ghost button and fix discover slider visibility toggle position ([#3226](https://github.com/fallenbagel/jellyseerr/issues/3226)) ([2eebb7f](https://github.com/fallenbagel/jellyseerr/commit/2eebb7fd3941b34fe9472aaf9d28265df8cce311))
* restore status badges on titles on actors page when hide available media enabled ([#3206](https://github.com/fallenbagel/jellyseerr/issues/3206)) ([9d3446d](https://github.com/fallenbagel/jellyseerr/commit/9d3446d370499c3251159393e5c791b01225e05c))
* screen would zoom on mobile if date picker input was selected ([#3241](https://github.com/fallenbagel/jellyseerr/issues/3241)) ([3aefddd](https://github.com/fallenbagel/jellyseerr/commit/3aefddd48834d86150d5f5cceb2d08af3a78847b))
* series displayed an empty season with series list/request modal ([#3147](https://github.com/fallenbagel/jellyseerr/issues/3147)) ([2179637](https://github.com/fallenbagel/jellyseerr/commit/2179637d437999290eaa4152f6f37c71fc3d8ba3))
* tooltip shows properly if not in progress ([#3185](https://github.com/fallenbagel/jellyseerr/issues/3185)) ([6face8c](https://github.com/fallenbagel/jellyseerr/commit/6face8cc4564b978fb98af32659b326d8c5cede8))
* **ui:** series first air date sorting ([#3283](https://github.com/fallenbagel/jellyseerr/issues/3283)) ([374c78c](https://github.com/fallenbagel/jellyseerr/commit/374c78c989cc86bb144a954a91d5d183c4b591c0))
* update StatusBadgeMini to shrink on title cards (and remove ring) ([#3210](https://github.com/fallenbagel/jellyseerr/issues/3210)) ([042a1a9](https://github.com/fallenbagel/jellyseerr/commit/042a1a950fdd4d4a61edf4bc19657f9b7a526da8))
### Features
* add discover customization ([#3182](https://github.com/fallenbagel/jellyseerr/issues/3182)) ([cd35748](https://github.com/fallenbagel/jellyseerr/commit/cd3574851a12517cbfadc109e6412a7a9e44c114))
* add keywords to movie/series detail pages ([#3204](https://github.com/fallenbagel/jellyseerr/issues/3204)) ([e084649](https://github.com/fallenbagel/jellyseerr/commit/e084649878a58c296786141d12dd69a69a27ee85))
* add streaming services filter ([#3247](https://github.com/fallenbagel/jellyseerr/issues/3247)) ([1154156](https://github.com/fallenbagel/jellyseerr/commit/1154156459403494e8daf0c89a3ba356aeea1d97))
* discover inline customization ([#3220](https://github.com/fallenbagel/jellyseerr/issues/3220)) ([8bd10b5](https://github.com/fallenbagel/jellyseerr/commit/8bd10b5bf3d1b8069872b616c7c8596caeb4937e))
* discover overhaul (filters!) ([#3232](https://github.com/fallenbagel/jellyseerr/issues/3232)) ([dd00e48](https://github.com/fallenbagel/jellyseerr/commit/dd00e48f59054b44bef6b32a2c169e59f6175051))
* discover slider edit arrow buttons for reordering ([#3259](https://github.com/fallenbagel/jellyseerr/issues/3259)) ([da00d45](https://github.com/fallenbagel/jellyseerr/commit/da00d454e17e8b00d04f6e26f6dd5153ed6ced81))
* **lang:** translations update from Hosted Weblate ([#3030](https://github.com/fallenbagel/jellyseerr/issues/3030)) ([0d8b390](https://github.com/fallenbagel/jellyseerr/commit/0d8b390b678731e76bd1f0f8a0a4952c11e77f4d))
* new mobile menu ([#3251](https://github.com/fallenbagel/jellyseerr/issues/3251)) ([fcbca17](https://github.com/fallenbagel/jellyseerr/commit/fcbca1722f31f32633a57bc5048f46c9da057d87))
* translations update from Hosted Weblate ([#3218](https://github.com/fallenbagel/jellyseerr/issues/3218)) ([5940ff7](https://github.com/fallenbagel/jellyseerr/commit/5940ff7f5f62eed9ac5aa6f02803418aaa09813a))
* **ui:** add episode number to front of episode name in season details ([#3086](https://github.com/fallenbagel/jellyseerr/issues/3086)) ([a672b32](https://github.com/fallenbagel/jellyseerr/commit/a672b324ec391a20f6f3a1daed82a8d276a52c2c))
* **ui:** request card progress bar ([#3123](https://github.com/fallenbagel/jellyseerr/issues/3123)) ([03853a1](https://github.com/fallenbagel/jellyseerr/commit/03853a1b9155c8a2153c8885022a74619af1bc15))
# [1.3.0](https://github.com/fallenbagel/jellyseerr/compare/v1.2.1...v1.3.0) (2023-01-02) # [1.3.0](https://github.com/fallenbagel/jellyseerr/compare/v1.2.1...v1.3.0) (2023-01-02)
### Bug Fixes ### Bug Fixes

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://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></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="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-48-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- 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,16 +137,6 @@ 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>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -1,5 +1,5 @@
const testUser = { const testUser = {
username: 'Test User', displayName: '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('#username').type(testUser.username); cy.get('#displayName').type(testUser.displayName);
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

@@ -248,7 +248,6 @@ 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:
@@ -273,7 +272,6 @@ 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-derivation">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">override the package derivation</a>.
</Admonition> </Admonition>
) : ( ) : (
<Admonition type="success"> <Admonition type="success">

View File

@@ -12,8 +12,6 @@ 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--discover-language) to suit their own preferences. Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-and-discover-language) to suit their own preferences.
### Movie Request Limit & Series Request Limit ### Movie Request Limit & Series Request Limit

View File

@@ -6,10 +6,15 @@ module.exports = {
commitTag: process.env.COMMIT_TAG || 'local', commitTag: process.env.COMMIT_TAG || 'local',
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false', 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: [
{ hostname: 'gravatar.com' }, { hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' }, { hostname: 'image.tmdb.org' },
{ hostname: '*', protocol: 'https' },
], ],
}, },
webpack(config) { webpack(config) {

View File

@@ -38,8 +38,6 @@ tags:
description: Endpoints related to getting service (Radarr/Sonarr) details. description: Endpoints related to getting service (Radarr/Sonarr) details.
- name: watchlist - name: watchlist
description: Collection of media to watch later description: Collection of media to watch later
- name: blacklist
description: Blacklisted media from discovery page.
servers: servers:
- url: '{server}/api/v1' - url: '{server}/api/v1'
variables: variables:
@@ -48,19 +46,6 @@ servers:
components: components:
schemas: schemas:
Blacklist:
type: object
properties:
tmdbId:
type: number
example: 1
title:
type: string
media:
$ref: '#/components/schemas/MediaInfo'
userId:
type: number
example: 1
Watchlist: Watchlist:
type: object type: object
properties: properties:
@@ -2790,15 +2775,6 @@ paths:
imageCount: imageCount:
type: number type: number
example: 123 example: 123
avatar:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches: apiCaches:
type: array type: array
items: items:
@@ -3610,8 +3586,6 @@ paths:
type: string type: string
email: email:
type: string type: string
serverType:
type: number
required: required:
- username - username
- password - password
@@ -4066,94 +4040,6 @@ paths:
restricted: restricted:
type: boolean type: boolean
example: false example: false
/blacklist:
get:
summary: Returns blacklisted items
description: Returns list of all blacklisted media
tags:
- settings
parameters:
- in: query
name: take
schema:
type: number
nullable: true
example: 25
- in: query
name: skip
schema:
type: number
nullable: true
example: 0
- in: query
name: search
schema:
type: string
nullable: true
example: dune
responses:
'200':
description: Blacklisted items returned
content:
application/json:
schema:
type: object
properties:
pageInfo:
$ref: '#/components/schemas/PageInfo'
results:
type: array
items:
type: object
properties:
user:
$ref: '#/components/schemas/User'
createdAt:
type: string
example: 2024-04-21T01:55:44.000Z
id:
type: number
example: 1
mediaType:
type: string
example: movie
title:
type: string
example: Dune
tmdbId:
type: number
example: 438631
post:
summary: Add media to blacklist
tags:
- blacklist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Blacklist'
responses:
'201':
description: Item succesfully blacklisted
'412':
description: Item has already been blacklisted
/blacklist/{tmdbId}:
delete:
summary: Remove media from blacklist
tags:
- blacklist
parameters:
- in: path
name: tmdbId
description: tmdbId ID
required: true
example: '1'
schema:
type: string
responses:
'204':
description: Succesfully removed media item
/watchlist: /watchlist:
post: post:
summary: Add media to watchlist summary: Add media to watchlist
@@ -4976,11 +4862,6 @@ 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

@@ -1,6 +1,6 @@
{ {
"name": "jellyseerr", "name": "jellyseerr",
"version": "2.0.1", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
@@ -62,7 +62,6 @@
"formik": "^2.4.6", "formik": "^2.4.6",
"gravatar-url": "3.1.0", "gravatar-url": "3.1.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"mime": "3",
"next": "^14.2.4", "next": "^14.2.4",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-gyp": "9.3.1", "node-gyp": "9.3.1",
@@ -120,7 +119,6 @@
"@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/mime": "3",
"@types/node": "20.14.8", "@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",

25
pnpm-lock.yaml generated
View File

@@ -98,9 +98,6 @@ importers:
lodash: lodash:
specifier: 4.17.21 specifier: 4.17.21
version: 4.17.21 version: 4.17.21
mime:
specifier: '3'
version: 3.0.0
next: next:
specifier: ^14.2.4 specifier: ^14.2.4
version: 14.2.4(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 14.2.4(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -267,9 +264,6 @@ importers:
'@types/lodash': '@types/lodash':
specifier: 4.14.191 specifier: 4.14.191
version: 4.14.191 version: 4.14.191
'@types/mime':
specifier: '3'
version: 3.0.4
'@types/node': '@types/node':
specifier: 20.14.8 specifier: 20.14.8
version: 20.14.8 version: 20.14.8
@@ -2854,9 +2848,6 @@ packages:
'@types/mime@1.3.5': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/mime@3.0.4':
resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==}
'@types/minimatch@3.0.5': '@types/minimatch@3.0.5':
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
@@ -10845,7 +10836,7 @@ snapshots:
nopt: 5.0.0 nopt: 5.0.0
npmlog: 5.0.1 npmlog: 5.0.1
rimraf: 3.0.2 rimraf: 3.0.2
semver: 7.3.8 semver: 7.6.2
tar: 6.2.1 tar: 6.2.1
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@@ -10920,13 +10911,13 @@ snapshots:
'@npmcli/fs@1.1.1': '@npmcli/fs@1.1.1':
dependencies: dependencies:
'@gar/promisify': 1.1.3 '@gar/promisify': 1.1.3
semver: 7.3.8 semver: 7.6.2
optional: true optional: true
'@npmcli/fs@2.1.2': '@npmcli/fs@2.1.2':
dependencies: dependencies:
'@gar/promisify': 1.1.3 '@gar/promisify': 1.1.3
semver: 7.3.8 semver: 7.6.2
'@npmcli/move-file@1.1.2': '@npmcli/move-file@1.1.2':
dependencies: dependencies:
@@ -12335,7 +12326,7 @@ snapshots:
read-pkg: 5.2.0 read-pkg: 5.2.0
registry-auth-token: 5.0.2 registry-auth-token: 5.0.2
semantic-release: 19.0.5(encoding@0.1.13) semantic-release: 19.0.5(encoding@0.1.13)
semver: 7.3.8 semver: 7.6.2
tempy: 1.0.1 tempy: 1.0.1
'@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))': '@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))':
@@ -12679,8 +12670,6 @@ snapshots:
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}
'@types/mime@3.0.4': {}
'@types/minimatch@3.0.5': {} '@types/minimatch@3.0.5': {}
'@types/minimist@1.2.5': {} '@types/minimist@1.2.5': {}
@@ -12898,7 +12887,7 @@ snapshots:
debug: 4.3.5(supports-color@8.1.1) debug: 4.3.5(supports-color@8.1.1)
globby: 11.1.0 globby: 11.1.0
is-glob: 4.0.3 is-glob: 4.0.3
semver: 7.3.8 semver: 7.6.2
tsutils: 3.21.0(typescript@4.9.5) tsutils: 3.21.0(typescript@4.9.5)
optionalDependencies: optionalDependencies:
typescript: 4.9.5 typescript: 4.9.5
@@ -17280,7 +17269,7 @@ snapshots:
nopt: 5.0.0 nopt: 5.0.0
npmlog: 6.0.2 npmlog: 6.0.2
rimraf: 3.0.2 rimraf: 3.0.2
semver: 7.3.8 semver: 7.6.2
tar: 6.2.1 tar: 6.2.1
which: 2.0.2 which: 2.0.2
transitivePeerDependencies: transitivePeerDependencies:
@@ -17359,7 +17348,7 @@ snapshots:
dependencies: dependencies:
hosted-git-info: 4.1.0 hosted-git-info: 4.1.0
is-core-module: 2.14.0 is-core-module: 2.14.0
semver: 7.3.8 semver: 7.6.2
validate-npm-package-license: 3.0.4 validate-npm-package-license: 3.0.4
normalize-path@3.0.0: {} normalize-path@3.0.0: {}

View File

@@ -68,15 +68,12 @@ class ExternalAPI {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
throw new Error( throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`, `${response.status} ${response.statusText}${text ? ': ' + text : ''}`
{
cause: response,
}
); );
} }
const data = await this.getDataFromResponse(response); const data = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) { if (this.cache) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
} }
@@ -85,7 +82,7 @@ class ExternalAPI {
protected async post<T>( protected async post<T>(
endpoint: string, endpoint: string,
data?: Record<string, unknown>, data: Record<string, unknown>,
params?: Record<string, string>, params?: Record<string, string>,
ttl?: number, ttl?: number,
config?: RequestInit config?: RequestInit
@@ -107,20 +104,11 @@ class ExternalAPI {
...this.defaultHeaders, ...this.defaultHeaders,
...config?.headers, ...config?.headers,
}, },
body: data ? JSON.stringify(data) : undefined, 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); const resData = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) { if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
} }
@@ -153,18 +141,9 @@ class ExternalAPI {
}, },
body: JSON.stringify(data), 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); const resData = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) { if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
} }
@@ -178,22 +157,12 @@ class ExternalAPI {
): Promise<T> { ): Promise<T> {
const url = this.formatUrl(endpoint, params); const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, { const response = await this.fetch(url, {
method: 'DELETE',
...config, ...config,
headers: { headers: {
...this.defaultHeaders, ...this.defaultHeaders,
...config?.headers, ...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); const data = await this.getDataFromResponse(response);
return data; return data;
@@ -228,17 +197,6 @@ class ExternalAPI {
...config?.headers, ...config?.headers,
}, },
}).then(async (response) => { }).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); const data = await this.getDataFromResponse(response);
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL); this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}); });
@@ -254,15 +212,6 @@ class ExternalAPI {
...config?.headers, ...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); const data = await this.getDataFromResponse(response);
if (this.cache) { if (this.cache) {
@@ -286,12 +235,7 @@ class ExternalAPI {
...this.params, ...this.params,
...params, ...params,
}); });
return ( return `${href}?${searchParams.toString()}`;
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
} }
private serializeCacheKey( private serializeCacheKey(
@@ -306,24 +250,20 @@ class ExternalAPI {
} }
private async getDataFromResponse(response: Response) { private async getDataFromResponse(response: Response) {
const contentType = response.headers.get('Content-Type'); const contentType = response.headers.get('Content-Type')?.split(';')[0];
if (contentType?.includes('application/json')) { if (contentType === 'application/json') {
return await response.json(); return await response.json();
} else if ( } else if (
contentType?.includes('application/xml') || contentType === 'application/xml' ||
contentType?.includes('text/html') || contentType === 'text/html' ||
contentType?.includes('text/plain') contentType === 'text/plain'
) { ) {
return await response.text(); return await response.text();
} else { } else {
try { try {
return await response.json(); return await response.json();
} catch { } catch {
try { return await response.blob();
return await response.blob();
} catch {
return null;
}
} }
} }
} }

View File

@@ -93,7 +93,9 @@ 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;
@@ -112,6 +114,9 @@ class JellyfinAPI extends ExternalAPI {
}, },
} }
); );
this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
} }
public async login( public async login(
@@ -147,7 +152,7 @@ class JellyfinAPI extends ExternalAPI {
try { try {
return await authenticate(false); return await authenticate(false);
} catch (e) { } catch (e) {
const status = e.cause?.status; const status = e.response?.status;
const networkErrorCodes = new Set([ const networkErrorCodes = new Set([
'ECONNREFUSED', 'ECONNREFUSED',
@@ -185,7 +190,7 @@ class JellyfinAPI extends ExternalAPI {
return systemInfoResponse; return systemInfoResponse;
} catch (e) { } catch (e) {
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -202,7 +207,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown); throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
} }
} }
@@ -217,7 +222,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -233,7 +238,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -312,7 +317,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -333,7 +338,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -348,7 +353,7 @@ class JellyfinAPI extends ExternalAPI {
return itemResponse; return itemResponse;
} catch (e) { } catch (e) {
if (availabilitySync.running) { if (availabilitySync.running) {
if (e.cause?.status === 500) { if (e.response && e.response.status === 500) {
return undefined; return undefined;
} }
} }
@@ -357,7 +362,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.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -372,7 +377,7 @@ class JellyfinAPI extends ExternalAPI {
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -397,23 +402,6 @@ 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

@@ -175,11 +175,7 @@ class IMDBRadarrProxy extends ExternalAPI {
`/movie/imdb/${IMDBid}` `/movie/imdb/${IMDBid}`
); );
if ( if (!data?.length || data[0].ImdbId !== IMDBid) {
!data?.length ||
data[0].ImdbId !== IMDBid ||
!data[0].MovieRatings.Imdb
) {
return null; return null;
} }

View File

@@ -182,7 +182,7 @@ class RottenTomatoes extends ExternalAPI {
); );
} }
if (!tvshow || !tvshow.rottenTomatoes) { if (!tvshow) {
return null; return null;
} }

View File

@@ -157,13 +157,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => { public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try { try {
const data = await this.get<QueueResponse<QueueItemAppendT>>( const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
`/queue`, includeEpisode: 'true',
{ });
includeEpisode: 'true',
},
0
);
return data.records; return data.records;
} catch (e) { } catch (e) {
@@ -197,24 +193,15 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
} }
}; };
async refreshMonitoredDownloads(): Promise<void> {
await this.runCommand('RefreshMonitoredDownloads', {});
}
protected async runCommand( protected async runCommand(
commandName: string, commandName: string,
options: Record<string, unknown> options: Record<string, unknown>
): Promise<void> { ): Promise<void> {
try { try {
await this.post( await this.post(`/command`, {
`/command`, name: commandName,
{ ...options,
name: commandName, });
...options,
},
{},
0
);
} catch (e) { } catch (e) {
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`); throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
} }

View File

@@ -179,20 +179,13 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
} }
return 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: errorData, response: e?.response?.data,
} }
); );
throw new Error('Failed to add movie to Radarr'); throw new Error('Failed to add movie to Radarr');

View File

@@ -257,18 +257,11 @@ class SonarrAPI extends ServarrBase<{
return createdSeriesData; 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: errorData, response: e?.response?.data,
}); });
throw new Error('Failed to add series'); throw new Error('Failed to add series');
} }
@@ -303,10 +296,10 @@ class SonarrAPI extends ServarrBase<{
}); });
try { try {
await this.runCommand('MissingEpisodeSearch', { seriesId }); await this.runCommand('SeriesSearch', { seriesId });
} catch (e) { } catch (e) {
logger.error( logger.error(
'Something went wrong while executing Sonarr missing episode search.', 'Something went wrong while executing Sonarr series search.',
{ {
label: 'Sonarr API', label: 'Sonarr API',
errorMessage: e.message, errorMessage: e.message,

View File

@@ -95,7 +95,6 @@ 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 {
@@ -524,7 +523,6 @@ 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(
@@ -572,7 +570,6 @@ class TheMovieDb extends ExternalAPI {
'vote_count.lte': voteCountLte || '', 'vote_count.lte': voteCountLte || '',
with_watch_providers: watchProviders || '', with_watch_providers: watchProviders || '',
watch_region: watchRegion || '', watch_region: watchRegion || '',
with_status: withStatus || '',
}); });
return data; return data;

View File

@@ -2,7 +2,6 @@ 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

@@ -16,5 +16,4 @@ export enum MediaStatus {
PROCESSING, PROCESSING,
PARTIALLY_AVAILABLE, PARTIALLY_AVAILABLE,
AVAILABLE, AVAILABLE,
BLACKLISTED,
} }

View File

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

View File

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

View File

@@ -1,95 +0,0 @@
import { MediaStatus, type MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
@Entity()
@Unique(['tmdbId'])
export class Blacklist implements BlacklistItem {
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column({ nullable: true, type: 'varchar' })
title?: string;
@Column()
@Index()
public tmdbId: number;
@ManyToOne(() => User, (user) => user.id, {
eager: true,
})
user: User;
@OneToOne(() => Media, (media) => media.blacklist, {
onDelete: 'CASCADE',
})
@JoinColumn()
public media: Media;
@CreateDateColumn()
public createdAt: Date;
constructor(init?: Partial<Blacklist>) {
Object.assign(this, init);
}
public static async addToBlacklist({
blacklistRequest,
}: {
blacklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
};
}): Promise<void> {
const blacklist = new this({
...blacklistRequest,
});
const mediaRepository = getRepository(Media);
let media = await mediaRepository.findOne({
where: {
tmdbId: blacklistRequest.tmdbId,
},
});
const blacklistRepository = getRepository(this);
await blacklistRepository.save(blacklist);
if (!media) {
media = new Media({
tmdbId: blacklistRequest.tmdbId,
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: blacklist,
});
await mediaRepository.save(media);
} else {
media.blacklist = blacklist;
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;
await mediaRepository.save(media);
}
}
}

View File

@@ -3,7 +3,6 @@ import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import type { User } from '@server/entity/User'; import type { User } from '@server/entity/User';
import { Watchlist } from '@server/entity/Watchlist'; import { Watchlist } from '@server/entity/Watchlist';
import type { DownloadingItem } from '@server/lib/downloadtracker'; import type { DownloadingItem } from '@server/lib/downloadtracker';
@@ -18,7 +17,6 @@ import {
Entity, Entity,
Index, Index,
OneToMany, OneToMany,
OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
@@ -68,7 +66,7 @@ class Media {
try { try {
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType: mediaType }, where: { tmdbId: id, mediaType },
relations: { requests: true, issues: true }, relations: { requests: true, issues: true },
}); });
@@ -118,11 +116,6 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true }) @OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[]; public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
eager: true,
})
public blacklist: Blacklist;
@CreateDateColumn() @CreateDateColumn()
public createdAt: Date; public createdAt: Date;
@@ -218,10 +211,9 @@ class Media {
} }
} else { } else {
const pageName = const pageName =
getSettings().main.mediaServerType == MediaServerType.EMBY process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
? '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
@@ -231,7 +223,7 @@ class Media {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
} }
if (this.jellyfinMediaId4k) { if (this.jellyfinMediaId4k) {
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`; this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
} }
} }
} }

View File

@@ -40,7 +40,6 @@ export class RequestPermissionError extends Error {}
export class QuotaRestrictedError extends Error {} export class QuotaRestrictedError extends Error {}
export class DuplicateMediaRequestError extends Error {} export class DuplicateMediaRequestError extends Error {}
export class NoSeasonsAvailableError extends Error {} export class NoSeasonsAvailableError extends Error {}
export class BlacklistedMediaError extends Error {}
type MediaRequestOptions = { type MediaRequestOptions = {
isAutoRequest?: boolean; isAutoRequest?: boolean;
@@ -144,16 +143,6 @@ export class MediaRequest {
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
}); });
} else { } else {
if (media.status === MediaStatus.BLACKLISTED) {
logger.warn('Request for media blocked due to being blacklisted', {
tmdbId: tmdbMedia.id,
mediaType: requestBody.mediaType,
label: 'Media Request',
});
throw new BlacklistedMediaError('This media is blacklisted.');
}
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
media.status = MediaStatus.PENDING; media.status = MediaStatus.PENDING;
} }

View File

@@ -19,7 +19,6 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies'; import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes'; import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy'; import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag'; import restartFlag from '@server/utils/restartFlag';
@@ -64,7 +63,7 @@ app
} }
// Load Settings // Load Settings
const settings = await getSettings().load(); const settings = getSettings();
restartFlag.initializeSettings(settings.main); restartFlag.initializeSettings(settings.main);
// Migrate library types // Migrate library types
@@ -175,7 +174,7 @@ app
}, },
store: new TypeormStore({ store: new TypeormStore({
cleanupLimit: 2, cleanupLimit: 2,
ttl: 60 * 60 * 24 * 30, ttl: 1000 * 60 * 60 * 24 * 30,
}).connect(sessionRespository) as Store, }).connect(sessionRespository) as Store,
}) })
); );
@@ -203,7 +202,6 @@ app
// Do not set cookies so CDNs can cache them // Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy); server.use('/imageproxy', clearCookies, imageproxy);
server.use('/avatarproxy', clearCookies, avatarproxy);
server.get('*', (req, res) => handle(req, res)); server.get('*', (req, res) => handle(req, res));
server.use( server.use(

View File

@@ -1,14 +0,0 @@
import type { User } from '@server/entity/User';
import type { PaginatedResponse } from '@server/interfaces/api/common';
export interface BlacklistItem {
tmdbId: number;
mediaType: 'movie' | 'tv';
title?: string;
createdAt?: Date;
user: User;
}
export interface BlacklistResultsResponse extends PaginatedResponse {
results: BlacklistItem[];
}

View File

@@ -8,16 +8,3 @@ 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 { NonFunctionProperties, PaginatedResponse } from './common'; import type { PaginatedResponse } from './common';
export interface RequestResultsResponse extends PaginatedResponse { export interface RequestResultsResponse extends PaginatedResponse {
results: NonFunctionProperties<MediaRequest>[]; results: MediaRequest[];
} }
export type MediaRequestBody = { export type MediaRequestBody = {
@@ -14,7 +14,6 @@ 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

@@ -58,7 +58,7 @@ export interface CacheItem {
export interface CacheResponse { export interface CacheResponse {
apiCaches: CacheItem[]; apiCaches: CacheItem[];
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>; imageCache: Record<'tmdb', { size: number; imageCount: number }>;
} }
export interface StatusResponse { export interface StatusResponse {

View File

@@ -227,9 +227,6 @@ export const startJobs = (): void => {
}); });
// Clean TMDB image cache // Clean TMDB image cache
ImageProxy.clearCache('tmdb'); ImageProxy.clearCache('tmdb');
// Clean users avatar image cache
ImageProxy.clearCache('avatar');
}), }),
}); });

View File

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

View File

@@ -20,7 +20,6 @@ export interface DownloadingItem {
timeLeft: string; timeLeft: string;
estimatedCompletionTime: Date; estimatedCompletionTime: Date;
title: string; title: string;
downloadId: string;
episode?: EpisodeNumberResult; episode?: EpisodeNumberResult;
} }
@@ -85,7 +84,6 @@ class DownloadTracker {
}); });
try { try {
await radarr.refreshMonitoredDownloads();
const queueItems = await radarr.getQueue(); const queueItems = await radarr.getQueue();
this.radarrServers[server.id] = queueItems.map((item) => ({ this.radarrServers[server.id] = queueItems.map((item) => ({
@@ -97,7 +95,6 @@ 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) {
@@ -163,7 +160,6 @@ class DownloadTracker {
}); });
try { try {
await sonarr.refreshMonitoredDownloads();
const queueItems = await sonarr.getQueue(); const queueItems = await sonarr.getQueue();
this.sonarrServers[server.id] = queueItems.map((item) => ({ this.sonarrServers[server.id] = queueItems.map((item) => ({
@@ -176,7 +172,6 @@ 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

@@ -3,7 +3,6 @@ import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit'; import rateLimit from '@server/utils/rateLimit';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { promises } from 'fs'; import { promises } from 'fs';
import mime from 'mime/lite';
import path, { join } from 'path'; import path, { join } from 'path';
type ImageResponse = { type ImageResponse = {
@@ -12,7 +11,7 @@ type ImageResponse = {
curRevalidate: number; curRevalidate: number;
isStale: boolean; isStale: boolean;
etag: string; etag: string;
extension: string | null; extension: string;
cacheKey: string; cacheKey: string;
cacheMiss: boolean; cacheMiss: boolean;
}; };
@@ -28,45 +27,29 @@ class ImageProxy {
let deletedImages = 0; let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key); const cacheDirectory = path.join(baseCacheDirectory, key);
try { const files = await promises.readdir(cacheDirectory);
const files = await promises.readdir(cacheDirectory);
for (const file of files) { for (const file of files) {
const filePath = path.join(cacheDirectory, file); const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath); const stat = await promises.lstat(filePath);
if (stat.isDirectory()) { if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath); const imageFiles = await promises.readdir(filePath);
for (const imageFile of imageFiles) { for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.'); const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt); const expireAt = Number(expireAtSt);
const now = Date.now(); const now = Date.now();
if (now > expireAt) { if (now > expireAt) {
await promises.rm(path.join(filePath), { await promises.rm(path.join(filePath, imageFile));
recursive: true, deletedImages += 1;
});
deletedImages += 1;
}
} }
} }
} }
} catch (e) {
if (e.code === 'ENOENT') {
logger.error('Directory not found', {
label: 'Image Cache',
message: e.message,
});
} else {
logger.error('Failed to read directory', {
label: 'Image Cache',
message: e.message,
});
}
} }
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, { logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
label: 'Image Cache', label: 'Image Cache',
}); });
} }
@@ -86,49 +69,33 @@ class ImageProxy {
} }
private static async getDirectorySize(dir: string): Promise<number> { private static async getDirectorySize(dir: string): Promise<number> {
try { const files = await promises.readdir(dir, {
const files = await promises.readdir(dir, { withFileTypes: true,
withFileTypes: true, });
});
const paths = files.map(async (file) => { const paths = files.map(async (file) => {
const path = join(dir, file.name); const path = join(dir, file.name);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isFile()) { if (file.isFile()) {
const { size } = await promises.stat(path); const { size } = await promises.stat(path);
return size; return size;
}
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
} }
}
return 0; return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} }
private static async getImageCount(dir: string) { private static async getImageCount(dir: string) {
try { const files = await promises.readdir(dir);
const files = await promises.readdir(dir);
return files.length; return files.length;
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
return 0;
} }
private fetch: typeof fetch; private fetch: typeof fetch;
@@ -180,27 +147,6 @@ class ImageProxy {
return imageResponse; return imageResponse;
} }
public async clearCachedImage(path: string) {
// find cacheKey
const cacheKey = this.getCacheKey(path);
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
await promises.rm(directory, { recursive: true });
logger.info(`Cleared ${files[0]} from cache 'avatar'`, {
label: 'Image Cache',
});
} catch (e) {
logger.error('Failed to clear cached image', {
label: 'Image Cache',
message: e.message,
});
}
}
private async get(cacheKey: string): Promise<ImageResponse | null> { private async get(cacheKey: string): Promise<ImageResponse | null> {
try { try {
const directory = join(this.getCacheDirectory(), cacheKey); const directory = join(this.getCacheDirectory(), cacheKey);
@@ -241,25 +187,16 @@ class ImageProxy {
const directory = join(this.getCacheDirectory(), cacheKey); const directory = join(this.getCacheDirectory(), cacheKey);
const href = const href =
this.baseUrl + this.baseUrl +
(this.baseUrl.length > 0 (this.baseUrl.endsWith('/') ? '' : '/') +
? this.baseUrl.endsWith('/')
? ''
: '/'
: '') +
(path.startsWith('/') ? path.slice(1) : path); (path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href); const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const extension = mime.getExtension( const extension = path.split('.').pop() ?? '';
response.headers.get('content-type') ?? '' const maxAge = Number(
);
let maxAge = Number(
(response.headers.get('cache-control') ?? '0').split('=')[1] (response.headers.get('cache-control') ?? '0').split('=')[1]
); );
if (!maxAge) maxAge = 86400;
const expireAt = Date.now() + maxAge * 1000; const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.get('etag') ?? '').replace(/"/g, ''); const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
@@ -295,7 +232,7 @@ class ImageProxy {
private async writeToCacheDir( private async writeToCacheDir(
dir: string, dir: string,
extension: string | null, extension: string,
maxAge: number, maxAge: number,
expireAt: number, expireAt: number,
buffer: Buffer, buffer: Buffer,

View File

@@ -291,7 +291,7 @@ class DiscordAgent
} }
} }
const response = await fetch(settings.options.webhookUrl, { await fetch(settings.options.webhookUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -305,25 +305,15 @@ class DiscordAgent
content: userMentions.join(' '), content: userMentions.join(' '),
} as DiscordWebhookPayload), } 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: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -132,32 +132,22 @@ 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);
const response = await fetch(endpoint, { await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(notificationPayload), 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: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -100,7 +100,7 @@ class LunaSeaAgent
}); });
try { try {
const response = await fetch(settings.options.webhookUrl, { await fetch(settings.options.webhookUrl, {
method: 'POST', method: 'POST',
headers: settings.options.profileName headers: settings.options.profileName
? { ? {
@@ -114,25 +114,15 @@ class LunaSeaAgent
}, },
body: JSON.stringify(this.buildPayload(type, payload)), 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: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -122,7 +122,7 @@ class PushbulletAgent
}); });
try { try {
const response = await fetch(endpoint, { await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -133,23 +133,13 @@ class PushbulletAgent
channel_tag: settings.options.channelTag, 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: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -174,7 +164,7 @@ class PushbulletAgent
}); });
try { try {
const response = await fetch(endpoint, { await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -182,24 +172,14 @@ class PushbulletAgent
}, },
body: JSON.stringify(notificationPayload), 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: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -235,7 +215,7 @@ class PushbulletAgent
}); });
try { try {
const response = await fetch(endpoint, { await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -243,24 +223,14 @@ class PushbulletAgent
}, },
body: JSON.stringify(notificationPayload), 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: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -52,9 +52,6 @@ class PushoverAgent
): Promise<Partial<PushoverImagePayload>> { ): Promise<Partial<PushoverImagePayload>> {
try { try {
const response = await fetch(imageUrl); const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64'); const base64 = Buffer.from(arrayBuffer).toString('base64');
const contentType = ( const contentType = (
@@ -67,17 +64,10 @@ 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: errorData, response: e.response?.data,
}); });
return {}; return {};
} }
@@ -210,7 +200,7 @@ class PushoverAgent
}); });
try { try {
const response = await fetch(endpoint, { await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -222,23 +212,13 @@ class PushoverAgent
sound: settings.options.sound, sound: settings.options.sound,
} as PushoverPayload), } 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: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -266,7 +246,7 @@ class PushoverAgent
}); });
try { try {
const response = await fetch(endpoint, { await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -278,24 +258,14 @@ class PushoverAgent
sound: payload.notifyUser.settings.pushoverSound, sound: payload.notifyUser.settings.pushoverSound,
} as PushoverPayload), } 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: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -332,7 +302,7 @@ class PushoverAgent
}); });
try { try {
const response = await fetch(endpoint, { await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -343,24 +313,14 @@ class PushoverAgent
user: user.settings.pushoverUserKey, user: user.settings.pushoverUserKey,
} as PushoverPayload), } 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: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -237,32 +237,22 @@ class SlackAgent
subject: payload.subject, subject: payload.subject,
}); });
try { try {
const response = await fetch(settings.options.webhookUrl, { await fetch(settings.options.webhookUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(this.buildEmbed(type, payload)), 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: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -174,7 +174,7 @@ class TelegramAgent
}); });
try { try {
const response = await fetch(endpoint, { await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -185,23 +185,13 @@ class TelegramAgent
disable_notification: !!settings.options.sendSilently, disable_notification: !!settings.options.sendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload), } 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: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -225,7 +215,7 @@ class TelegramAgent
}); });
try { try {
const response = await fetch(endpoint, { await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -237,24 +227,14 @@ class TelegramAgent
!!payload.notifyUser.settings.telegramSendSilently, !!payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload), } 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: errorData, response: e.response?.data,
}); });
return false; return false;
@@ -288,7 +268,7 @@ class TelegramAgent
}); });
try { try {
const response = await fetch(endpoint, { await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -299,24 +279,14 @@ class TelegramAgent
disable_notification: !!user.settings?.telegramSendSilently, disable_notification: !!user.settings?.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload), } 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: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -177,7 +177,7 @@ class WebhookAgent
}); });
try { try {
const response = await fetch(settings.options.webhookUrl, { await fetch(settings.options.webhookUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -187,25 +187,15 @@ class WebhookAgent
}, },
body: JSON.stringify(this.buildPayload(type, payload)), 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 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: errorData, response: e.response?.data,
}); });
return false; return false;

View File

@@ -27,8 +27,6 @@ export enum Permission {
AUTO_REQUEST_TV = 33554432, AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864, RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728, WATCHLIST_VIEW = 134217728,
MANAGE_BLACKLIST = 268435456,
VIEW_BLACKLIST = 1073741824,
} }
export interface PermissionCheckOptions { export interface PermissionCheckOptions {

View File

@@ -567,10 +567,7 @@ class JellyfinScanner {
public async run(): Promise<void> { public async run(): Promise<void> {
const settings = getSettings(); const settings = getSettings();
if ( if (settings.main.mediaServerType != MediaServerType.JELLYFIN) {
settings.main.mediaServerType != MediaServerType.JELLYFIN &&
settings.main.mediaServerType != MediaServerType.EMBY
) {
return; return;
} }
@@ -585,7 +582,12 @@ 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: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], select: [
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
@@ -595,7 +597,7 @@ class JellyfinScanner {
this.jfClient = new JellyfinAPI( this.jfClient = new JellyfinAPI(
getHostname(), getHostname(),
settings.jellyfin.apiKey, admin.jellyfinAuthToken,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

@@ -47,7 +47,6 @@ 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;
@@ -343,7 +342,6 @@ class Settings {
jellyfinForgotPasswordUrl: '', jellyfinForgotPasswordUrl: '',
libraries: [], libraries: [],
serverId: '', serverId: '',
apiKey: '',
}, },
tautulli: {}, tautulli: {},
radarr: [], radarr: [],
@@ -611,11 +609,7 @@ class Settings {
} }
private generateApiKey(): string { private generateApiKey(): string {
if (process.env.API_KEY) { return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
return process.env.API_KEY;
} else {
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
}
} }
private generateVapidKeys(force = false): void { private generateVapidKeys(force = false): void {
@@ -635,7 +629,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 async load(overrideSettings?: AllSettings): Promise<Settings> { public load(overrideSettings?: AllSettings): Settings {
if (overrideSettings) { if (overrideSettings) {
this.data = overrideSettings; this.data = overrideSettings;
return this; return this;
@@ -648,16 +642,10 @@ class Settings {
if (data) { if (data) {
const parsedJson = JSON.parse(data); const parsedJson = JSON.parse(data);
this.data = await runMigrations(parsedJson); this.data = 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;
@@ -668,6 +656,7 @@ class Settings {
} }
} }
let loaded = false;
let settings: Settings | undefined; let settings: Settings | undefined;
export const getSettings = (initialSettings?: AllSettings): Settings => { export const getSettings = (initialSettings?: AllSettings): Settings => {
@@ -675,6 +664,11 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
settings = new Settings(initialSettings); settings = new Settings(initialSettings);
} }
if (!loaded) {
settings.load();
loaded = true;
}
return settings; return settings;
}; };

View File

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

@@ -1,16 +0,0 @@
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,13 +1,10 @@
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 = async ( export const runMigrations = (settings: AllSettings): AllSettings => {
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'))
@@ -16,15 +13,8 @@ export const runMigrations = async (
let migrated = settings; let migrated = settings;
try { for (const migration of migrations) {
for (const migration of migrations) { migrated = migration(migrated);
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

@@ -1,20 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlacklist1699901142442 implements MigrationInterface {
name = 'AddBlacklist1699901142442';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')),"userId" integer, "mediaId" integer,CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
}
}

View File

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

View File

@@ -111,7 +111,6 @@ 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 => ({
@@ -162,8 +161,7 @@ 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,
@@ -225,5 +223,4 @@ export const mapTvDetails = (
})), })),
mediaInfo: media, mediaInfo: media,
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}), watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
onUserWatchlist: userWatchlist,
}); });

View File

@@ -1,12 +1,11 @@
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, ServerType } from '@server/constants/server'; 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 { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule'; import { startJobs } from '@server/job/schedule';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions'; 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';
@@ -228,20 +227,15 @@ 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 && Emby is not already configured //Make sure jellyfin login is enabled, but only if jellyfin is not already configured
if ( if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN && settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.main.mediaServerType !== MediaServerType.EMBY && settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
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
@@ -262,6 +256,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
urlBase: body.urlBase, urlBase: body.urlBase,
}); });
const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one // Try to find deviceId that corresponds to jellyfin user, else generate a new one
let user = await userRepository.findOne({ let user = await userRepository.findOne({
where: { jellyfinUsername: body.username }, where: { jellyfinUsername: body.username },
@@ -277,7 +273,11 @@ 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 =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
const ip = req.ip; const ip = req.ip;
let clientIp; let clientIp;
@@ -317,57 +317,20 @@ 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 permissions // with admin permission
switch (body.serverType) { settings.main.mediaServerType = MediaServerType.JELLYFIN;
case MediaServerType.EMBY: user = new User({
settings.main.mediaServerType = MediaServerType.EMBY; email: body.email,
user = new User({ jellyfinUsername: account.User.Name,
email: body.email || account.User.Name, jellyfinUserId: account.User.Id,
jellyfinUsername: account.User.Name, jellyfinDeviceId: deviceId,
jellyfinUserId: account.User.Id, jellyfinAuthToken: account.AccessToken,
jellyfinDeviceId: deviceId, permissions: Permission.ADMIN,
jellyfinAuthToken: account.AccessToken, avatar: account.User.PrimaryImageTag
permissions: Permission.ADMIN, ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
avatar: account.User.PrimaryImageTag : gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }),
? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` userType: UserType.JELLYFIN,
: 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
? `/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();
@@ -377,7 +340,6 @@ 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();
@@ -388,12 +350,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
? ServerType.JELLYFIN ? 'Jellyfin'
: ServerType.EMBY : 'Emby'
} user; updating user with ${ } user; updating user with ${
settings.main.mediaServerType === MediaServerType.JELLYFIN settings.main.mediaServerType === MediaServerType.JELLYFIN
? ServerType.JELLYFIN ? 'Jellyfin'
: ServerType.EMBY : 'Emby'
}`, }`,
{ {
label: 'API', label: 'API',
@@ -401,26 +363,18 @@ 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) {
const avatar = `/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`;
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
} else { } else {
const avatar = gravatarUrl(user.email || account.User.Name, { user.avatar = gravatarUrl(user.email, {
default: 'mm', default: 'mm',
size: 200, size: 200,
}); });
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
} }
user.jellyfinUsername = account.User.Name; user.jellyfinUsername = account.User.Name;
@@ -428,6 +382,12 @@ 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(
@@ -453,24 +413,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
? `/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 || account.User.Name, { : gravatarUrl(body.email, { default: 'mm', size: 200 }),
default: 'mm', userType: UserType.JELLYFIN,
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;
if (passedExplicitPassword) { if (passedExplicitPassword) {

View File

@@ -1,51 +0,0 @@
import { MediaServerType } from '@server/constants/server';
import ImageProxy from '@server/lib/imageproxy';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
const router = Router();
const avatarImageProxy = new ImageProxy('avatar', '');
// Proxy avatar images
router.get('/*', async (req, res) => {
let imagePath = '';
try {
const jellyfinAvatar = req.url.match(
/(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/
)?.[1];
if (!jellyfinAvatar) {
const mediaServerType = getSettings().main.mediaServerType;
throw new Error(
`Provided URL is not ${
mediaServerType === MediaServerType.JELLYFIN
? 'a Jellyfin'
: 'an Emby'
} avatar.`
);
}
const imageUrl = new URL(jellyfinAvatar, getHostname());
imagePath = imageUrl.toString();
const imageData = await avatarImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy avatar image', {
imagePath,
errorMessage: e.message,
});
}
});
export default router;

View File

@@ -1,148 +0,0 @@
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media';
import { NotFoundError } from '@server/entity/Watchlist';
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { QueryFailedError } from 'typeorm';
import { z } from 'zod';
const blacklistRoutes = Router();
export const blacklistAdd = z.object({
tmdbId: z.coerce.number(),
mediaType: z.nativeEnum(MediaType),
title: z.coerce.string().optional(),
user: z.coerce.number(),
});
blacklistRoutes.get(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
type: 'or',
}),
rateLimit({ windowMs: 60 * 1000, max: 50 }),
async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const search = (req.query.search as string) ?? '';
try {
let query = getRepository(Blacklist)
.createQueryBuilder('blacklist')
.leftJoinAndSelect('blacklist.user', 'user');
if (search.length > 0) {
query = query.where('blacklist.title like :title', {
title: `%${search}%`,
});
}
const [blacklistedItems, itemsCount] = await query
.orderBy('blacklist.createdAt', 'DESC')
.take(pageSize)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(itemsCount / pageSize),
pageSize,
results: itemsCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: blacklistedItems,
} as BlacklistResultsResponse);
} catch (error) {
logger.error('Something went wrong while retrieving blacklisted items', {
label: 'Blacklist',
errorMessage: error.message,
});
return next({
status: 500,
message: 'Unable to retrieve blacklisted items.',
});
}
}
);
blacklistRoutes.post(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const values = blacklistAdd.parse(req.body);
await Blacklist.addToBlacklist({
blacklistRequest: values,
});
return res.status(201).send();
} catch (error) {
if (!(error instanceof Error)) {
return;
}
if (error instanceof QueryFailedError) {
switch (error.driverError.errno) {
case 19:
return next({ status: 412, message: 'Item already blacklisted' });
default:
logger.warn('Something wrong with data blacklist', {
tmdbId: req.body.tmdbId,
mediaType: req.body.mediaType,
label: 'Blacklist',
});
return next({ status: 409, message: 'Something wrong' });
}
}
return next({ status: 500, message: error.message });
}
}
);
blacklistRoutes.delete(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
await blacklisteRepository.remove(blacklistItem);
const mediaRepository = getRepository(Media);
const mediaItem = await mediaRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
await mediaRepository.remove(mediaItem);
return res.status(204).send();
} catch (e) {
if (e instanceof NotFoundError) {
return next({
status: 401,
message: e.message,
});
}
return next({ status: 500, message: e.message });
}
}
);
export default blacklistRoutes;

View File

@@ -71,7 +71,6 @@ 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>;
@@ -386,7 +385,6 @@ 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

@@ -23,7 +23,6 @@ import restartFlag from '@server/utils/restartFlag';
import { isPerson } from '@server/utils/typeHelpers'; import { isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express'; import { Router } from 'express';
import authRoutes from './auth'; import authRoutes from './auth';
import blacklistRoutes from './blacklist';
import collectionRoutes from './collection'; import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import issueRoutes from './issue'; import issueRoutes from './issue';
@@ -145,7 +144,6 @@ router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/discover', isAuthenticated(), discoverRoutes);
router.use('/request', isAuthenticated(), requestRoutes); router.use('/request', isAuthenticated(), requestRoutes);
router.use('/watchlist', isAuthenticated(), watchlistRoutes); router.use('/watchlist', isAuthenticated(), watchlistRoutes);
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
router.use('/movie', isAuthenticated(), movieRoutes); router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes); router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/media', isAuthenticated(), mediaRoutes); router.use('/media', isAuthenticated(), mediaRoutes);

View File

@@ -3,9 +3,7 @@ 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';
@@ -24,24 +22,7 @@ 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);
const onUserWatchlist = await getRepository(Watchlist).exist({ return res.status(200).json(mapMovieDetails(tmdbMovie, media));
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,5 +1,3 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { import {
MediaRequestStatus, MediaRequestStatus,
MediaStatus, MediaStatus,
@@ -8,7 +6,6 @@ import {
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { import {
BlacklistedMediaError,
DuplicateMediaRequestError, DuplicateMediaRequestError,
MediaRequest, MediaRequest,
NoSeasonsAvailableError, NoSeasonsAvailableError,
@@ -22,7 +19,6 @@ 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';
@@ -147,62 +143,6 @@ 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),
@@ -210,7 +150,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
results: requestCount, results: requestCount,
page: Math.ceil(skip / pageSize) + 1, page: Math.ceil(skip / pageSize) + 1,
}, },
results: requestsWithProfileNames, results: requests,
}); });
} catch (e) { } catch (e) {
next({ status: 500, message: e.message }); next({ status: 500, message: e.message });
@@ -244,8 +184,6 @@ requestRoutes.post<never, MediaRequest, MediaRequestBody>(
return next({ status: 409, message: error.message }); return next({ status: 409, message: error.message });
case NoSeasonsAvailableError: case NoSeasonsAvailableError:
return next({ status: 202, message: error.message }); return next({ status: 202, message: error.message });
case BlacklistedMediaError:
return next({ status: 403, message: error.message });
default: default:
return next({ status: 500, message: error.message }); return next({ status: 500, message: error.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', 'jellyfinUserId', 'jellyfinDeviceId'], select: ['id', 'jellyfinAuthToken', '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),
tempJellyfinSettings.apiKey, admin.jellyfinAuthToken ?? '',
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', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 }, where: { id: 1 },
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(), getHostname(),
settings.jellyfin.apiKey, admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -376,17 +376,21 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
}); });
settingsRoutes.get('/jellyfin/users', async (req, res) => { settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings(); const { externalHostname } = getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: getHostname();
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 }, where: { id: 1 },
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(), getHostname(),
settings.jellyfin.apiKey, admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -396,7 +400,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
username: user.Name, username: user.Name,
id: user.Id, id: user.Id,
thumb: user.PrimaryImageTag thumb: user.PrimaryImageTag
? `/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: gravatarUrl(user.Name, { default: 'mm', size: 200 }), : gravatarUrl(user.Name, { default: 'mm', size: 200 }),
email: user.Name, email: user.Name,
})); }));
@@ -741,13 +745,11 @@ settingsRoutes.get('/cache', async (_req, res) => {
})); }));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
return res.status(200).json({ return res.status(200).json({
apiCaches, apiCaches,
imageCache: { imageCache: {
tmdb: tmdbImageCache, tmdb: tmdbImageCache,
avatar: avatarImageCache,
}, },
}); });
}); });

View File

@@ -1,9 +1,7 @@
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';
@@ -21,24 +19,7 @@ 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);
const onUserWatchlist = await getRepository(Watchlist).exist({ return res.status(200).json(mapTvDetails(tv, media));
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,7 +2,6 @@ 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';
@@ -42,19 +41,7 @@ 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.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.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;
@@ -103,13 +90,12 @@ 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: email.toLowerCase(), email: body.email.toLowerCase(),
}) })
.getOne(); .getOne();
@@ -122,7 +108,7 @@ router.post(
} }
const passedExplicitPassword = body.password && body.password.length > 0; const passedExplicitPassword = body.password && body.password.length > 0;
const avatar = gravatarUrl(email, { default: 'mm', size: 200 }); const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
if ( if (
!passedExplicitPassword && !passedExplicitPassword &&
@@ -132,9 +118,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: '',
@@ -502,20 +488,30 @@ 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: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], select: [
'id',
'jellyfinAuthToken',
'jellyfinDeviceId',
'jellyfinUserId',
],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const hostname = getHostname();
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
hostname, getHostname(),
settings.jellyfin.apiKey, admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
//const jellyfinUsersResponse = await jellyfinClient.getUsers(); //const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = []; const createdUsers: User[] = [];
const { externalHostname } = getSettings().jellyfin;
const hostname = getHostname();
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers(); const jellyfinUsers = await jellyfinClient.getUsers();
@@ -540,15 +536,12 @@ router.post(
email: jellyfinUser?.Name, email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: jellyfinUser?.PrimaryImageTag avatar: jellyfinUser?.PrimaryImageTag
? `/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
: gravatarUrl(jellyfinUser?.Name ?? '', { : gravatarUrl(jellyfinUser?.Name ?? '', {
default: 'mm', default: 'mm',
size: 200, size: 200,
}), }),
userType: userType: UserType.JELLYFIN,
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
}); });
await userRepository.save(newUser); await userRepository.save(newUser);

View File

@@ -1,4 +1,3 @@
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';
@@ -10,7 +9,6 @@ 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 '.';
@@ -100,17 +98,7 @@ userSettingsRoutes.post<
} }
user.username = req.body.username; user.username = req.body.username;
const oldEmail = user.email; user.email = req.body.email ?? 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 (
@@ -155,14 +143,7 @@ userSettingsRoutes.post<
email: savedUser.email, email: savedUser.email,
}); });
} catch (e) { } catch (e) {
if (e.errorCode) { next({ status: 500, message: e.message });
return next({
status: e.statusCode,
message: e.errorCode,
});
} else {
return next({ status: 500, message: e.message });
}
} }
}); });

View File

@@ -7,10 +7,9 @@ type RateLimiteState<T extends (...args: Parameters<T>) => Promise<U>, U> = {
queue: { queue: {
args: Parameters<T>; args: Parameters<T>;
resolve: (value: U) => void; resolve: (value: U) => void;
reject: (reason?: unknown) => void;
}[]; }[];
lastTimestamps: number[]; activeRequests: number;
timeout: ReturnType<typeof setTimeout>; timer: NodeJS.Timeout | null;
}; };
const rateLimitById: Record<string, unknown> = {}; const rateLimitById: Record<string, unknown> = {};
@@ -28,40 +27,46 @@ export default function rateLimit<
>(fn: T, options: RateLimitOptions): (...args: Parameters<T>) => Promise<U> { >(fn: T, options: RateLimitOptions): (...args: Parameters<T>) => Promise<U> {
const state: RateLimiteState<T, U> = (rateLimitById[ const state: RateLimiteState<T, U> = (rateLimitById[
options.id || '' options.id || ''
] as RateLimiteState<T, U>) || { queue: [], lastTimestamps: [] }; ] as RateLimiteState<T, U>) || { queue: [], activeRequests: 0, timer: null };
if (options.id) { if (options.id) {
rateLimitById[options.id] = state; rateLimitById[options.id] = state;
} }
const processQueue = () => { const processQueue = () => {
// remove old timestamps if (state.queue.length === 0) {
state.lastTimestamps = state.lastTimestamps.filter( if (state.timer) {
(timestamp) => Date.now() - timestamp < 1000 clearInterval(state.timer);
); state.timer = null;
}
return;
}
if (state.lastTimestamps.length < options.maxRPS) { while (state.activeRequests < options.maxRPS) {
// process requests if RPS not exceeded state.activeRequests++;
const item = state.queue.shift(); const item = state.queue.shift();
if (!item) return; if (!item) break;
state.lastTimestamps.push(Date.now()); const { args, resolve } = item;
const { args, resolve, reject } = item;
fn(...args) fn(...args)
.then(resolve) .then(resolve)
.catch(reject); .finally(() => {
processQueue(); state.activeRequests--;
} else { if (state.queue.length > 0) {
// rerun once the oldest item in queue is older than 1s if (!state.timer) {
if (state.timeout) clearTimeout(state.timeout); state.timer = setInterval(processQueue, 1000);
state.timeout = setTimeout( }
processQueue, } else {
1000 - (Date.now() - state.lastTimestamps[0]) if (state.timer) {
); clearInterval(state.timer);
state.timer = null;
}
}
});
} }
}; };
return (...args: Parameters<T>): Promise<U> => { return (...args: Parameters<T>): Promise<U> => {
return new Promise<U>((resolve, reject) => { return new Promise<U>((resolve) => {
state.queue.push({ args, resolve, reject }); state.queue.push({ args, resolve });
processQueue(); processQueue();
}); });
}; };

View File

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

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,131 +1,46 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" <?xml version="1.0" encoding="UTF-8" standalone="no"?>
y="0px" width="100%" viewBox="0 0 617 188" enable-background="new 0 0 617 188" xml:space="preserve"> <svg
<path fill="#52B54B" opacity="1.000000" stroke="none" d=" version="1.1"
M89.583336,1.000000 id="svg2"
C90.189529,1.685005 90.166168,2.574803 90.599510,3.025271 viewBox="0 0 712.60077 712.5481"
C103.718315,16.662701 116.882103,30.256845 129.948212,43.764053 xmlns="http://www.w3.org/2000/svg"
C130.577850,43.523941 130.916519,43.491173 131.111343,43.306595 xmlns:svg="http://www.w3.org/2000/svg"
C138.657471,36.157455 138.655273,36.156090 146.005478,43.505203 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
C159.538589,57.036308 173.016449,70.623535 186.654617,84.047913 xmlns:cc="http://creativecommons.org/ns#"
C189.264145,86.616562 189.414017,88.253456 186.716782,90.895164 xmlns:dc="http://purl.org/dc/elements/1.1/">
C174.709808,102.655037 162.893280,114.609337 151.008514,126.493958 <defs
C146.073502,131.428925 146.076691,131.427155 151.017944,136.523712 id="defs4" />
C151.698944,137.226120 152.340485,137.966812 153.259171,138.973434 <metadata
C151.947098,140.380035 150.766312,141.712204 149.516266,142.975861 id="metadata7">
C134.544815,158.110641 119.563087,173.235260 104.792023,188.681274 <rdf:RDF>
C103.611107,189.000000 102.222221,189.000000 100.624634,188.681274 <cc:Work
C86.361732,174.796494 72.307518,161.230438 57.702755,147.132965 rdf:about="">
C56.157101,149.136856 54.135899,151.757263 51.994804,154.533112 <dc:format>image/svg+xml</dc:format>
C35.932781,138.457108 20.569420,123.048477 5.141897,107.704361 <dc:type
C3.997114,106.565773 2.391420,105.890610 1.000000,105.000000 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
C1.000000,103.611107 1.000000,102.222221 1.318741,100.624641 </cc:Work>
C15.203506,86.361694 28.769531,72.307434 42.867004,57.702602 </rdf:RDF>
C40.863205,56.156994 38.242813,54.135792 35.425343,51.962570 </metadata>
C51.518696,35.908516 66.939468,20.557360 82.295547,5.141749 <rect
C83.434830,3.998048 84.109390,2.391417 85.000000,0.999999 style="opacity:0;fill:#ffffff;stroke-width:4.12543"
C86.388893,1.000000 87.777779,1.000000 89.583336,1.000000 id="rect249"
M73.196465,79.500702 width="712.60077"
C73.196465,96.254150 73.196465,113.007599 73.196465,130.872055 height="712.5481"
C94.273178,118.764557 114.417175,107.192863 135.221664,95.241745 x="-0.00071160076"
C114.247169,83.251732 94.091187,71.729622 73.196594,59.785294 y="2.0223413e-11" />
C73.196594,66.631348 73.196594,72.566254 73.196465,79.500702 <rect
z" /> style="fill:#ffffff"
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d=" id="rect289"
M618.000000,60.571537 width="230.18982"
C617.004395,62.042580 615.613281,62.912964 615.073181,64.153824 height="229.82355"
C608.143372,80.073746 601.328613,96.043816 594.498169,112.006920 x="241.20476"
C586.973572,129.592300 579.343018,147.133865 571.999390,164.794601 y="241.36227" />
C568.632385,172.892075 568.893372,173.002594 560.133972,172.999832 <g
C555.470825,172.998367 550.807617,172.994385 546.144592,172.969360 id="layer1"
C545.841980,172.967712 545.540466,172.775543 544.836609,172.534256 transform="matrix(0.70249853,0,0,0.70249853,88.770447,96.84571)">
C548.592896,163.531219 551.714905,154.222061 556.286133,145.689255 <path
C559.733765,139.253830 559.138794,134.062668 556.454224,127.695969 id="path3427"
C546.360352,103.757523 536.803345,79.592712 526.837830,55.000847 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"
C534.817078,55.000847 542.437622,54.725182 550.003540,55.244331 style="fill:#52b54b;fill-opacity:1;stroke:none" />
C551.436218,55.342628 553.169678,58.412052 553.885010,60.423309 </g>
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: 7.8 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,420 +0,0 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import useDebouncedState from '@app/hooks/useDebouncedState';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import {
ChevronLeftIcon,
ChevronRightIcon,
MagnifyingGlassIcon,
TrashIcon,
} from '@heroicons/react/24/solid';
import type {
BlacklistItem,
BlacklistResultsResponse,
} from '@server/interfaces/api/blacklistInterfaces';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import Link from 'next/link';
import { useRouter } from 'next/router';
import type { ChangeEvent } from 'react';
import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.Blacklist', {
blacklistsettings: 'Blacklist Settings',
blacklistSettingsDescription: 'Manage blacklisted media.',
mediaName: 'Name',
mediaType: 'Type',
mediaTmdbId: 'tmdb Id',
blacklistdate: 'date',
blacklistedby: '{date} by {user}',
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const Blacklist = () => {
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
useDebouncedState('');
const router = useRouter();
const intl = useIntl();
const page = router.query.page ? Number(router.query.page) : 1;
const pageIndex = page - 1;
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
const {
data,
error,
mutate: revalidate,
} = useSWR<BlacklistResultsResponse>(
`/api/v1/blacklist/?take=${currentPageSize}
&skip=${pageIndex * currentPageSize}
${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`,
{
refreshInterval: 0,
revalidateOnFocus: false,
}
);
// check if there's no data and no errors in the table
// so as to show a spinner inside the table and not refresh the whole component
if (!data && error) {
return <Error statusCode={500} />;
}
const searchItem = (e: ChangeEvent<HTMLInputElement>) => {
// Remove the "page" query param from the URL
// so that the "skip" query param on line 62 is empty
// and the search returns results without skipping items
if (router.query.page) router.replace(router.basePath);
setSearchFilter(e.target.value as string);
};
const hasNextPage = data && data.pageInfo.pages > pageIndex + 1;
const hasPrevPage = pageIndex > 0;
return (
<>
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<MagnifyingGlassIcon className="h-6 w-6" />
</span>
<input
type="text"
className="rounded-r-only"
value={searchFilter}
onChange={(e) => searchItem(e)}
/>
</div>
</div>
{!data ? (
<LoadingSpinner />
) : data.results.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center py-24 text-white">
<span className="text-2xl text-gray-400">
{intl.formatMessage(globalMessages.noresults)}
</span>
</div>
) : (
data.results.map((item: BlacklistItem) => {
return (
<div className="py-2" key={`request-list-${item.tmdbId}`}>
<BlacklistedItem item={item} revalidateList={revalidate} />
</div>
);
})
)}
<div className="actions">
<nav
className="mb-3 flex flex-col items-center space-y-3 sm:flex-row sm:space-y-0"
aria-label="Pagination"
>
<div className="hidden lg:flex lg:flex-1">
<p className="text-sm">
{data &&
(data?.results.length ?? 0) > 0 &&
intl.formatMessage(globalMessages.showingresults, {
from: pageIndex * currentPageSize + 1,
to:
data.results.length < currentPageSize
? pageIndex * currentPageSize + data.results.length
: (pageIndex + 1) * currentPageSize,
total: data.pageInfo.results,
strong: (msg: React.ReactNode) => (
<span className="font-medium">{msg}</span>
),
})}
</p>
</div>
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
<span className="-mt-3 items-center truncate text-sm sm:mt-0">
{intl.formatMessage(globalMessages.resultsperpage, {
pageSize: (
<select
id="pageSize"
name="pageSize"
onChange={(e) => {
setCurrentPageSize(Number(e.target.value));
router
.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
})
.then(() => window.scrollTo(0, 0));
}}
value={currentPageSize}
className="short inline"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
),
})}
</span>
</div>
<div className="flex flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end">
<Button
disabled={!hasPrevPage}
onClick={() => updateQueryParams('page', (page - 1).toString())}
>
<ChevronLeftIcon />
<span>{intl.formatMessage(globalMessages.previous)}</span>
</Button>
<Button
disabled={!hasNextPage}
onClick={() => updateQueryParams('page', (page + 1).toString())}
>
<span>{intl.formatMessage(globalMessages.next)}</span>
<ChevronRightIcon />
</Button>
</div>
</nav>
</div>
</>
);
};
export default Blacklist;
interface BlacklistedItemProps {
item: BlacklistItem;
revalidateList: () => void;
}
const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const { addToast } = useToasts();
const { ref, inView } = useInView({
triggerOnce: true,
});
const intl = useIntl();
const { hasPermission } = useUser();
const url =
item.mediaType === 'movie'
? `/api/v1/movie/${item.tmdbId}`
: `/api/v1/tv/${item.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? url : null
);
if (!title && !error) {
return (
<div
className="h-64 w-full animate-pulse rounded-xl bg-gray-800 xl:h-28"
ref={ref}
/>
);
}
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
method: 'DELETE',
});
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
revalidateList();
setIsUpdating(false);
};
return (
<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">
{title && title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
}}
/>
</div>
)}
<div className="relative flex w-full flex-col justify-between overflow-hidden sm:flex-row">
<div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
<Link
href={
item.mediaType === 'movie'
? `/movie/${item.tmdbId}`
: `/tv/${item.tmdbId}`
}
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
>
<CachedImage
type="tmdb"
src={
title?.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
width={600}
height={900}
/>
</Link>
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
{title &&
(isMovie(title)
? title.releaseDate
: title.firstAirDate
)?.slice(0, 4)}
</div>
<Link
href={
item.mediaType === 'movie'
? `/movie/${item.tmdbId}`
: `/tv/${item.tmdbId}`
}
>
<span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
{title && (isMovie(title) ? title.title : title.name)}
</span>
</Link>
</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="card-field">
<span className="card-field-name">Status</span>
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.blacklisted)}
</Badge>
</div>
{item.createdAt && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(globalMessages.blacklisted)}
</span>
<span className="flex truncate text-sm text-gray-300">
{intl.formatMessage(messages.blacklistedby, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(item.createdAt).getTime() - Date.now()) / 1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${item.user.id}`}>
<span className="group flex items-center truncate">
<CachedImage
type="avatar"
src={item.user.avatar}
alt=""
className="avatar-sm ml-1.5"
width={20}
height={20}
style={{ objectFit: 'cover' }}
/>
<span className="ml-1 truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{item.user.displayName}
</span>
</span>
</Link>
),
})}
</span>
</div>
)}
<div className="card-field">
{item.mediaType === 'movie' ? (
<div className="pointer-events-none z-40 self-start rounded-full border border-blue-500 bg-blue-600 bg-opacity-80 shadow-md">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{intl.formatMessage(globalMessages.movie)}
</div>
</div>
) : (
<div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{intl.formatMessage(globalMessages.tvshow)}
</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">
{hasPermission(Permission.MANAGE_BLACKLIST) && (
<ConfirmButton
onClick={() =>
removeFromBlacklist(
item.tmdbId,
title && (isMovie(title) ? title.title : title.name)
)
}
confirmText={intl.formatMessage(
isUpdating ? globalMessages.deleting : globalMessages.areyousure
)}
className={`w-full ${
isUpdating ? 'pointer-events-none opacity-50' : ''
}`}
>
<TrashIcon />
<span>
{intl.formatMessage(globalMessages.removefromBlacklist)}
</span>
</ConfirmButton>
)}
</div>
</div>
);
};

View File

@@ -1,129 +0,0 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
import type { Blacklist } from '@server/entity/Blacklist';
import Link from 'next/link';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages('component.BlacklistBlock', {
blacklistedby: 'Blacklisted By',
blacklistdate: 'Blacklisted date',
});
interface BlacklistBlockProps {
blacklistItem: Blacklist;
onUpdate?: () => void;
onDelete?: () => void;
}
const BlacklistBlock = ({
blacklistItem,
onUpdate,
onDelete,
}: BlacklistBlockProps) => {
const { user } = useUser();
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts();
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
method: 'DELETE',
});
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
onUpdate && onUpdate();
onDelete && onDelete();
setIsUpdating(false);
};
return (
<div className="px-4 py-3 text-gray-300">
<div className="flex items-center justify-between">
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="white mb-1 flex flex-nowrap">
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
blacklistItem.user.id === user?.id
? '/profile'
: `/users/${blacklistItem.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{blacklistItem.user.displayName}
</span>
</Link>
</span>
</div>
</div>
<div className="ml-2 flex flex-shrink-0 flex-wrap">
<Tooltip
content={intl.formatMessage(globalMessages.removefromBlacklist)}
>
<Button
buttonType="danger"
onClick={() =>
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
}
disabled={isUpdating}
>
<TrashIcon className="icon-sm" />
</Button>
</Tooltip>
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex">
<div className="mr-6 flex items-center text-sm leading-5">
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.blacklisted)}
</Badge>
</div>
</div>
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
<Tooltip content={intl.formatMessage(messages.blacklistdate)}>
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<span>
{intl.formatDate(blacklistItem.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
</div>
</div>
);
};
export default BlacklistBlock;

View File

@@ -1,79 +0,0 @@
import Modal from '@app/components/Common/Modal';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
interface BlacklistModalProps {
tmdbId: number;
type: 'movie' | 'tv' | 'collection';
show: boolean;
onComplete?: () => void;
onCancel?: () => void;
isUpdating?: boolean;
}
const messages = defineMessages('component.BlacklistModal', {
blacklisting: 'Blacklisting',
});
const isMovie = (
movie: MovieDetails | TvDetails | undefined
): movie is MovieDetails => {
if (!movie) return false;
return (movie as MovieDetails).title !== undefined;
};
const BlacklistModal = ({
tmdbId,
type,
show,
onComplete,
onCancel,
isUpdating,
}: BlacklistModalProps) => {
const intl = useIntl();
const { data, error } = useSWR<TvDetails | MovieDetails>(
`/api/v1/${type}/${tmdbId}`
);
return (
<Transition
as="div"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}
>
<Modal
loading={!data && !error}
backgroundClickable
title={`${intl.formatMessage(globalMessages.blacklist)} ${
isMovie(data)
? intl.formatMessage(globalMessages.movie)
: intl.formatMessage(globalMessages.tvshow)
}`}
subTitle={`${isMovie(data) ? data.title : data?.name}`}
onCancel={onCancel}
onOk={onComplete}
okText={
isUpdating
? intl.formatMessage(messages.blacklisting)
: intl.formatMessage(globalMessages.blacklist)
}
okButtonType="danger"
okDisabled={isUpdating}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
/>
</Transition>
);
};
export default BlacklistModal;

View File

@@ -183,11 +183,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
); );
} }
const blacklistVisibility = hasPermission(
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
{ type: 'or' }
);
return ( return (
<div <div
className="media-page" className="media-page"
@@ -198,7 +193,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
{data.backdropPath && ( {data.backdropPath && (
<div className="media-page-bg-image"> <div className="media-page-bg-image">
<CachedImage <CachedImage
type="tmdb"
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -229,7 +223,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<div className="media-header"> <div className="media-header">
<div className="media-poster"> <div className="media-poster">
<CachedImage <CachedImage
type="tmdb"
src={ src={
data.posterPath data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -342,26 +335,20 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
sliderKey="collection-movies" sliderKey="collection-movies"
isLoading={false} isLoading={false}
isEmpty={data.parts.length === 0} isEmpty={data.parts.length === 0}
items={data.parts items={data.parts.map((title) => (
.filter((title) => { <TitleCard
if (!blacklistVisibility) key={`collection-movie-${title.id}`}
return title.mediaInfo?.status !== MediaStatus.BLACKLISTED; id={title.id}
return title; isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
}) image={title.posterPath}
.map((title) => ( status={title.mediaInfo?.status}
<TitleCard summary={title.overview}
key={`collection-movie-${title.id}`} title={title.title}
id={title.id} userScore={title.voteAverage}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} year={title.releaseDate}
image={title.posterPath} mediaType={title.mediaType}
status={title.mediaInfo?.status} />
summary={title.overview} ))}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
/>
))}
/> />
<div className="extra-bottom-space relative" /> <div className="extra-bottom-space relative" />
</div> </div>

View File

@@ -4,34 +4,21 @@ import Image from 'next/image';
const imageLoader: ImageLoader = ({ src }) => src; const imageLoader: ImageLoader = ({ src }) => src;
export type CachedImageProps = ImageProps & {
src: string;
type: 'tmdb' | 'avatar';
};
/** /**
* The CachedImage component should be used wherever * The CachedImage component should be used wherever
* we want to offer the option to locally cache images. * we want to offer the option to locally cache images.
**/ **/
const CachedImage = ({ src, type, ...props }: CachedImageProps) => { const CachedImage = ({ src, ...props }: ImageProps) => {
const { currentSettings } = useSettings(); const { currentSettings } = useSettings();
let imageUrl: string; let imageUrl = src;
if (type === 'tmdb') { if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
// tmdb stuff const parsedUrl = new URL(imageUrl);
imageUrl =
currentSettings.cacheImages && !src.startsWith('/') if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/') imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
: src; }
} else if (type === 'avatar') {
// jellyfin avatar (in any)
const jellyfinAvatar = src.match(
/(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/
)?.[1];
imageUrl = jellyfinAvatar ? `/avatarproxy` + jellyfinAvatar : src;
} else {
return null;
} }
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />; return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;

View File

@@ -61,7 +61,6 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
{...props} {...props}
> >
<CachedImage <CachedImage
type="tmdb"
className="absolute inset-0 h-full w-full" className="absolute inset-0 h-full w-full"
alt="" alt=""
src={imageUrl} src={imageUrl}

View File

@@ -1,10 +1,8 @@
import PersonCard from '@app/components/PersonCard'; import PersonCard from '@app/components/PersonCard';
import TitleCard from '@app/components/TitleCard'; import TitleCard from '@app/components/TitleCard';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import useVerticalScroll from '@app/hooks/useVerticalScroll'; import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { MediaStatus } from '@server/constants/media';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type { import type {
CollectionResult, CollectionResult,
@@ -34,14 +32,7 @@ const ListView = ({
mutateParent, mutateParent,
}: ListViewProps) => { }: ListViewProps) => {
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd); useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
const blacklistVisibility = hasPermission(
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
{ type: 'or' }
);
return ( return (
<> <>
{isEmpty && ( {isEmpty && (
@@ -64,89 +55,76 @@ const ListView = ({
</li> </li>
); );
})} })}
{items {items?.map((title, index) => {
?.filter((title) => { let titleCard: React.ReactNode;
if (!blacklistVisibility)
return ( switch (title.mediaType) {
(title as TvResult | MovieResult).mediaInfo?.status !== case 'movie':
MediaStatus.BLACKLISTED titleCard = (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
); );
return title; break;
}) case 'tv':
.map((title, index) => { titleCard = (
let titleCard: React.ReactNode; <TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
break;
case 'collection':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
summary={title.overview}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'person':
titleCard = (
<PersonCard
personId={title.id}
name={title.name}
profilePath={title.profilePath}
canExpand
/>
);
break;
}
switch (title.mediaType) { return <li key={`${title.id}-${index}`}>{titleCard}</li>;
case 'movie': })}
titleCard = (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={
title.mediaInfo?.watchlists?.length ?? 0
}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
break;
case 'tv':
titleCard = (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={
title.mediaInfo?.watchlists?.length ?? 0
}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
break;
case 'collection':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
summary={title.overview}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'person':
titleCard = (
<PersonCard
personId={title.id}
name={title.name}
profilePath={title.profilePath}
canExpand
/>
);
break;
}
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
})}
{isLoading && {isLoading &&
!isReachingEnd && !isReachingEnd &&
[...Array(20)].map((_item, i) => ( [...Array(20)].map((_item, i) => (

View File

@@ -123,7 +123,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
{backdrop && ( {backdrop && (
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full"> <div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
<CachedImage <CachedImage
type="tmdb"
alt="" alt=""
src={backdrop} src={backdrop}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -1,11 +1,6 @@
import Spinner from '@app/assets/spinner.svg'; import Spinner from '@app/assets/spinner.svg';
import { CheckCircleIcon } from '@heroicons/react/20/solid'; import { CheckCircleIcon } from '@heroicons/react/20/solid';
import { import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
BellIcon,
ClockIcon,
EyeSlashIcon,
MinusSmallIcon,
} from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
interface StatusBadgeMiniProps { interface StatusBadgeMiniProps {
@@ -49,10 +44,6 @@ const StatusBadgeMini = ({
); );
indicatorIcon = <BellIcon />; indicatorIcon = <BellIcon />;
break; break;
case MediaStatus.BLACKLISTED:
badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white');
indicatorIcon = <EyeSlashIcon />;
break;
case MediaStatus.PARTIALLY_AVAILABLE: case MediaStatus.PARTIALLY_AVAILABLE:
badgeStyle.push( badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100' 'bg-green-500 border-green-400 ring-green-400 text-green-100'

View File

@@ -33,7 +33,6 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
> >
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<CachedImage <CachedImage
type="tmdb"
src={image} src={image}
alt={name} alt={name}
className="relative z-40 h-full w-full" className="relative z-40 h-full w-full"

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="relative mb-6 flex h-24 justify-center sm:h-32"> <div className="mb-6 flex justify-center">
<Image <Image
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`} src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
alt={firstResultData.network.name} alt={firstResultData.network.name}
className="object-contain" className="max-h-24 sm:max-h-32"
fill fill
/> />
</div> </div>

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="relative mb-6 flex h-24 justify-center sm:h-32"> <div className="mb-6 flex justify-center">
<Image <Image
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`} src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
alt={firstResultData.studio.name} alt={firstResultData.studio.name}
className="object-contain" className="max-h-24 sm:max-h-32"
fill fill
/> />
</div> </div>

View File

@@ -8,7 +8,6 @@ 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';
@@ -41,7 +40,6 @@ 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 = {
@@ -152,23 +150,6 @@ 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,7 +108,6 @@ 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>;
@@ -148,10 +147,6 @@ 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

@@ -11,6 +11,7 @@ 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';
@@ -30,6 +31,7 @@ const ExternalLinkBlock = ({
mediaUrl, mediaUrl,
}: ExternalLinkBlockProps) => { }: ExternalLinkBlockProps) => {
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const { locale } = useLocale(); const { locale } = useLocale();
return ( return (
@@ -43,8 +45,7 @@ const ExternalLinkBlock = ({
> >
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? ( {settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
<PlexLogo /> <PlexLogo />
) : settings.currentSettings.mediaServerType === ) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
MediaServerType.EMBY ? (
<EmbyLogo /> <EmbyLogo />
) : ( ) : (
<JellyfinLogo /> <JellyfinLogo />

View File

@@ -36,7 +36,6 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
tabIndex={0} tabIndex={0}
> >
<CachedImage <CachedImage
type="tmdb"
src={image} src={image}
alt="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -1,5 +1,4 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -7,6 +6,7 @@ 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 { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { FormattedRelativeTime, useIntl } from 'react-intl'; import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -88,8 +88,7 @@ const IssueComment = ({
</Modal> </Modal>
</Transition> </Transition>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}> <Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<CachedImage <Image
type="avatar"
src={comment.user.avatar} src={comment.user.avatar}
alt="" alt=""
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
@@ -182,9 +181,6 @@ const IssueComment = ({
`/api/v1/issueComment/${comment.id}`, `/api/v1/issueComment/${comment.id}`,
{ {
method: 'PUT', method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.newMessage }), body: JSON.stringify({ message: values.newMessage }),
} }
); );

View File

@@ -28,6 +28,8 @@ 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 { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
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';
import { useState } from 'react'; import { useState } from 'react';
@@ -106,6 +108,7 @@ 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 />;
@@ -123,9 +126,6 @@ const IssueDetails = () => {
try { try {
const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, { const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, {
method: 'PUT', method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: newMessage }), body: JSON.stringify({ message: newMessage }),
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
@@ -217,7 +217,6 @@ const IssueDetails = () => {
{data.backdropPath && ( {data.backdropPath && (
<div className="media-page-bg-image"> <div className="media-page-bg-image">
<CachedImage <CachedImage
type="tmdb"
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -236,7 +235,6 @@ const IssueDetails = () => {
<div className="media-header"> <div className="media-header">
<div className="media-poster"> <div className="media-poster">
<CachedImage <CachedImage
type="tmdb"
src={ src={
data.posterPath data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -288,11 +286,10 @@ const IssueDetails = () => {
} }
className="group ml-1 inline-flex h-full items-center xl:ml-1.5" className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
> >
<CachedImage <Image
type="avatar" className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
src={issueData.createdBy.avatar} src={issueData.createdBy.avatar}
alt="" alt=""
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
width={20} width={20}
height={20} height={20}
/> />
@@ -390,8 +387,7 @@ const IssueDetails = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{settings.currentSettings.mediaServerType === {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
MediaServerType.EMBY
? intl.formatMessage(messages.playonplex, { ? intl.formatMessage(messages.playonplex, {
mediaServerName: 'Emby', mediaServerName: 'Emby',
}) })
@@ -438,8 +434,7 @@ const IssueDetails = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{settings.currentSettings.mediaServerType === {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
MediaServerType.EMBY
? intl.formatMessage(messages.play4konplex, { ? intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Emby', mediaServerName: 'Emby',
}) })
@@ -506,9 +501,6 @@ const IssueDetails = () => {
`/api/v1/issue/${issueData?.id}/comment`, `/api/v1/issue/${issueData?.id}/comment`,
{ {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.message }), body: JSON.stringify({ message: values.message }),
} }
); );
@@ -664,8 +656,7 @@ const IssueDetails = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{settings.currentSettings.mediaServerType === {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
MediaServerType.EMBY
? intl.formatMessage(messages.playonplex, { ? intl.formatMessage(messages.playonplex, {
mediaServerName: 'Emby', mediaServerName: 'Emby',
}) })
@@ -711,8 +702,7 @@ const IssueDetails = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{settings.currentSettings.mediaServerType === {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
MediaServerType.EMBY
? intl.formatMessage(messages.play4konplex, { ? intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Emby', mediaServerName: 'Emby',
}) })

View File

@@ -11,6 +11,7 @@ import { MediaType } 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 Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { FormattedRelativeTime, useIntl } from 'react-intl'; import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -112,7 +113,6 @@ const IssueItem = ({ issue }: IssueItemProps) => {
{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
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -138,7 +138,6 @@ const IssueItem = ({ issue }: IssueItemProps) => {
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
> >
<CachedImage <CachedImage
type="tmdb"
src={ src={
title.posterPath title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
@@ -227,8 +226,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
href={`/users/${issue.createdBy.id}`} href={`/users/${issue.createdBy.id}`}
className="group flex items-center truncate" className="group flex items-center truncate"
> >
<CachedImage <Image
type="avatar"
src={issue.createdBy.avatar} src={issue.createdBy.avatar}
alt="" alt=""
className="avatar-sm ml-1.5 object-cover" className="avatar-sm ml-1.5 object-cover"

View File

@@ -7,7 +7,6 @@ import {
CogIcon, CogIcon,
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon, FilmIcon,
SparklesIcon, SparklesIcon,
TvIcon, TvIcon,
@@ -17,7 +16,6 @@ import {
ClockIcon as FilledClockIcon, ClockIcon as FilledClockIcon,
CogIcon as FilledCogIcon, CogIcon as FilledCogIcon,
ExclamationTriangleIcon as FilledExclamationTriangleIcon, ExclamationTriangleIcon as FilledExclamationTriangleIcon,
EyeSlashIcon as FilledEyeSlashIcon,
FilmIcon as FilledFilmIcon, FilmIcon as FilledFilmIcon,
SparklesIcon as FilledSparklesIcon, SparklesIcon as FilledSparklesIcon,
TvIcon as FilledTvIcon, TvIcon as FilledTvIcon,
@@ -86,18 +84,6 @@ const MobileMenu = () => {
svgIconSelected: <FilledClockIcon className="h-6 w-6" />, svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
activeRegExp: /^\/requests/, activeRegExp: /^\/requests/,
}, },
{
href: '/blacklist',
content: intl.formatMessage(menuMessages.blacklist),
svgIcon: <EyeSlashIcon className="h-6 w-6" />,
svgIconSelected: <FilledEyeSlashIcon className="h-6 w-6" />,
activeRegExp: /^\/blacklist/,
requiredPermission: [
Permission.MANAGE_BLACKLIST,
Permission.VIEW_BLACKLIST,
],
permissionType: 'or',
},
{ {
href: '/issues', href: '/issues',
content: intl.formatMessage(menuMessages.issues), content: intl.formatMessage(menuMessages.issues),

View File

@@ -8,7 +8,6 @@ import {
ClockIcon, ClockIcon,
CogIcon, CogIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon, FilmIcon,
SparklesIcon, SparklesIcon,
TvIcon, TvIcon,
@@ -26,7 +25,6 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
browsemovies: 'Movies', browsemovies: 'Movies',
browsetv: 'Series', browsetv: 'Series',
requests: 'Requests', requests: 'Requests',
blacklist: 'Blacklist',
issues: 'Issues', issues: 'Issues',
users: 'Users', users: 'Users',
settings: 'Settings', settings: 'Settings',
@@ -73,17 +71,6 @@ const SidebarLinks: SidebarLinkProps[] = [
svgIcon: <ClockIcon className="mr-3 h-6 w-6" />, svgIcon: <ClockIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/requests/, activeRegExp: /^\/requests/,
}, },
{
href: '/blacklist',
messagesKey: 'blacklist',
svgIcon: <EyeSlashIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/blacklist/,
requiredPermission: [
Permission.MANAGE_BLACKLIST,
Permission.VIEW_BLACKLIST,
],
permissionType: 'or',
},
{ {
href: '/issues', href: '/issues',
messagesKey: 'issues', messagesKey: 'issues',

View File

@@ -1,4 +1,3 @@
import CachedImage from '@app/components/Common/CachedImage';
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay'; import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -8,6 +7,7 @@ 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 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';
import { forwardRef, Fragment } from 'react'; import { forwardRef, Fragment } from 'react';
@@ -56,10 +56,9 @@ const UserDropdown = () => {
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500" className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
data-testid="user-menu" data-testid="user-menu"
> >
<CachedImage <Image
type="avatar"
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user ? user.avatar : ''} src={user?.avatar || ''}
alt="" alt=""
width={40} width={40}
height={40} height={40}
@@ -80,10 +79,9 @@ const UserDropdown = () => {
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur"> <div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
<div className="flex flex-col space-y-4 px-4 py-4"> <div className="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<CachedImage <Image
type="avatar"
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user ? user.avatar : ''} src={user?.avatar || ''}
alt="" alt=""
width={40} width={40}
height={40} height={40}
@@ -92,11 +90,9 @@ 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>
{user?.displayName?.toLowerCase() !== user?.email && ( <span className="truncate text-sm text-gray-400">
<span className="truncate text-sm text-gray-400"> {user?.email}
{user?.email} </span>
</span>
)}
</div> </div>
</div> </div>
{user && <MiniQuotaDisplay userId={user?.id} />} {user && <MiniQuotaDisplay userId={user?.id} />}

View File

@@ -59,9 +59,6 @@ const AddEmailModal: React.FC<AddEmailModalProps> = ({
try { try {
const res = await fetch('/api/v1/auth/jellyfin', { const res = await fetch('/api/v1/auth/jellyfin', {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
username: username, username: username,
password: password, password: password,

View File

@@ -4,9 +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 { MediaServerType, ServerType } from '@server/constants/server';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { FormattedMessage, useIntl } from 'react-intl'; import getConfig from 'next/config';
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';
@@ -26,7 +26,6 @@ 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,51 +40,42 @@ 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( intl.formatMessage(messages.validationhostrequired, {
messages.validationhostrequired, mediaServerName:
mediaServerFormatValues publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
) })
), ),
port: Yup.number().required( port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired) intl.formatMessage(messages.validationPortRequired)
), ),
urlBase: Yup.string().matches( urlBase: Yup.string()
/^(.*[^/])$/, .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)),
@@ -95,6 +85,11 @@ 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={{
@@ -109,11 +104,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
validationSchema={LoginSchema} validationSchema={LoginSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
// Check if serverType is either 'Jellyfin' or 'Emby'
// if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
// throw new Error('Invalid serverType'); // You can customize the error message
// }
const res = await fetch('/api/v1/auth/jellyfin', { const res = await fetch('/api/v1/auth/jellyfin', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -127,20 +117,12 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
useSsl: values.useSsl, useSsl: values.useSsl,
urlBase: values.urlBase, urlBase: values.urlBase,
email: values.email, email: values.email,
serverType: serverType,
}), }),
}); });
if (!res.ok) throw new Error(res.statusText, { cause: res }); if (!res.ok) throw new Error();
} 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 (errorData?.message) { switch (e.response?.data?.message) {
case ApiErrorCode.InvalidUrl: case ApiErrorCode.InvalidUrl:
errorMessage = messages.invalidurlerror; errorMessage = messages.invalidurlerror;
break; break;
@@ -323,7 +305,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 flex-row-reverse justify-between"> <div className="flex justify-end">
<span className="inline-flex rounded-md shadow-sm"> <span className="inline-flex rounded-md shadow-sm">
<Button <Button
buttonType="primary" buttonType="primary"
@@ -335,13 +317,6 @@ 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>
@@ -447,8 +422,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
jellyfinForgotPasswordUrl jellyfinForgotPasswordUrl
? `${jellyfinForgotPasswordUrl}` ? `${jellyfinForgotPasswordUrl}`
: `${baseUrl}/web/index.html#!/${ : `${baseUrl}/web/index.html#!/${
settings.currentSettings.mediaServerType === process.env.JELLYFIN_TYPE === 'emby'
MediaServerType.EMBY
? 'startup/' ? 'startup/'
: '' : ''
}forgotpassword.html` }forgotpassword.html`

View File

@@ -10,6 +10,7 @@ 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 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';
@@ -33,6 +34,7 @@ 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
@@ -43,26 +45,16 @@ const Login = () => {
try { try {
const res = await fetch('/api/v1/auth/plex', { const res = await fetch('/api/v1/auth/plex', {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ authToken }), body: JSON.stringify({ authToken }),
}); });
if (!res.ok) throw new Error(res.statusText, { cause: res }); if (!res.ok) throw new Error();
const data = await res.json(); const data = await res.json();
if (data?.id) { if (data?.id) {
revalidate(); revalidate();
} }
} catch (e) { } catch (e) {
let errorData; setError(e.response.data.message);
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
setError(errorData?.message);
setAuthToken(undefined); setAuthToken(undefined);
setProcessing(false); setProcessing(false);
} }
@@ -86,15 +78,6 @@ 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)} />
@@ -161,10 +144,12 @@ const Login = () => {
{settings.currentSettings.mediaServerType == {settings.currentSettings.mediaServerType ==
MediaServerType.PLEX MediaServerType.PLEX
? intl.formatMessage(messages.signinwithplex) ? intl.formatMessage(messages.signinwithplex)
: intl.formatMessage( : intl.formatMessage(messages.signinwithjellyfin, {
messages.signinwithjellyfin, mediaServerName:
mediaServerFormatValues publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
)} ? '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

@@ -1,6 +1,4 @@
import BlacklistBlock from '@app/components/BlacklistBlock';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton'; import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver'; import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
@@ -28,6 +26,8 @@ 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 getConfig from 'next/config';
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';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -95,6 +95,7 @@ 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 &&
@@ -285,20 +286,6 @@ const ManageSlideOver = ({
</div> </div>
</div> </div>
)} )}
{data.mediaInfo?.status === MediaStatus.BLACKLISTED && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(globalMessages.blacklist)}
</h3>
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<BlacklistBlock
blacklistItem={data.mediaInfo.blacklist}
onUpdate={() => revalidate()}
onDelete={() => onClose()}
/>
</div>
</div>
)}
{hasPermission(Permission.ADMIN) && {hasPermission(Permission.ADMIN) &&
(data.mediaInfo?.serviceUrl || (data.mediaInfo?.serviceUrl ||
data.mediaInfo?.tautulliUrl || data.mediaInfo?.tautulliUrl ||
@@ -368,8 +355,7 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`} key={`watch-user-${user.id}`}
content={user.displayName} content={user.displayName}
> >
<CachedImage <Image
type="avatar"
src={user.avatar} src={user.avatar}
alt={user.displayName} alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
@@ -530,8 +516,7 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`} key={`watch-user-${user.id}`}
content={user.displayName} content={user.displayName}
> >
<CachedImage <Image
type="avatar"
src={user.avatar} src={user.avatar}
alt={user.displayName} alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
@@ -620,17 +605,32 @@ const ManageSlideOver = ({
</div> </div>
</div> </div>
)} )}
{hasPermission(Permission.ADMIN) && {hasPermission(Permission.ADMIN) && data?.mediaInfo && (
data?.mediaInfo && <div>
data.mediaInfo.status !== MediaStatus.BLACKLISTED && ( <h3 className="mb-2 text-xl font-bold">
<div> {intl.formatMessage(messages.manageModalAdvanced)}
<h3 className="mb-2 text-xl font-bold"> </h3>
{intl.formatMessage(messages.manageModalAdvanced)} <div className="space-y-2">
</h3> {data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<div className="space-y-2"> <Button
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( onClick={() => markAvailable()}
className="w-full"
buttonType="success"
>
<CheckCircleIcon />
<span>
{intl.formatMessage(
mediaType === 'movie'
? messages.markavailable
: messages.markallseasonsavailable
)}
</span>
</Button>
)}
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled && (
<Button <Button
onClick={() => markAvailable()} onClick={() => markAvailable(true)}
className="w-full" className="w-full"
buttonType="success" buttonType="success"
> >
@@ -638,59 +638,41 @@ const ManageSlideOver = ({
<span> <span>
{intl.formatMessage( {intl.formatMessage(
mediaType === 'movie' mediaType === 'movie'
? messages.markavailable ? messages.mark4kavailable
: messages.markallseasonsavailable : messages.markallseasons4kavailable
)} )}
</span> </span>
</Button> </Button>
)} )}
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && <div>
settings.currentSettings.series4kEnabled && ( <ConfirmButton
<Button onClick={() => deleteMedia()}
onClick={() => markAvailable(true)} confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full" className="w-full"
buttonType="success" >
> <DocumentMinusIcon />
<CheckCircleIcon /> <span>
<span> {intl.formatMessage(messages.manageModalClearMedia)}
{intl.formatMessage( </span>
mediaType === 'movie' </ConfirmButton>
? messages.mark4kavailable <div className="mt-2 text-xs text-gray-400">
: messages.markallseasons4kavailable {intl.formatMessage(messages.manageModalClearMediaWarning, {
)} mediaType: intl.formatMessage(
</span> mediaType === 'movie' ? messages.movie : messages.tvshow
</Button> ),
)} mediaServerName:
<div> publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
<ConfirmButton ? 'Emby'
onClick={() => deleteMedia()} : settings.currentSettings.mediaServerType ===
confirmText={intl.formatMessage(globalMessages.areyousure)} MediaServerType.PLEX
className="w-full" ? 'Plex'
> : 'Jellyfin',
<DocumentMinusIcon /> })}
<span>
{intl.formatMessage(messages.manageModalClearMedia)}
</span>
</ConfirmButton>
<div className="mt-2 text-xs text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning, {
mediaType: intl.formatMessage(
mediaType === 'movie' ? messages.movie : messages.tvshow
),
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? 'Emby'
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
</div>
</div> </div>
</div> </div>
</div> </div>
)} </div>
)}
</div> </div>
</SlideOver> </SlideOver>
); );

View File

@@ -57,48 +57,44 @@ 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 grid h-full w-full grid-cols-2 items-center justify-center gap-2 opacity-30"> <div className="relative z-10 flex h-full flex-wrap items-center justify-center opacity-30">
{posters[0] && ( {posters[0] && (
<div className=""> <div className="w-1/2 p-1">
<Image <Image
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`} src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
alt="" alt=""
className="rounded-md" className="w-full rounded-md"
width={300} fill
height={450}
/> />
</div> </div>
)} )}
{posters[1] && ( {posters[1] && (
<div className=""> <div className="w-1/2 p-1">
<Image <Image
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`} src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
alt="" alt=""
className="rounded-md" className="w-full rounded-md"
width={300} fill
height={450}
/> />
</div> </div>
)} )}
{posters[2] && ( {posters[2] && (
<div className=""> <div className="w-1/2 p-1">
<Image <Image
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`} src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
alt="" alt=""
className="rounded-md" className="w-full rounded-md"
width={300} fill
height={450}
/> />
</div> </div>
)} )}
{posters[3] && ( {posters[3] && (
<div className=""> <div className="w-1/2 p-1">
<Image <Image
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`} src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
alt="" alt=""
className="rounded-md" className="w-full rounded-md"
width={300} fill
height={450}
/> />
</div> </div>
)} )}

View File

@@ -3,10 +3,8 @@ import PersonCard from '@app/components/PersonCard';
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import TitleCard from '@app/components/TitleCard'; import TitleCard from '@app/components/TitleCard';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import { Permission } from '@server/lib/permissions';
import type { import type {
MovieResult, MovieResult,
PersonResult, PersonResult,
@@ -43,7 +41,6 @@ const MediaSlider = ({
onNewTitles, onNewTitles,
}: MediaSliderProps) => { }: MediaSliderProps) => {
const settings = useSettings(); const settings = useSettings();
const { hasPermission } = useUser();
const { data, error, setSize, size } = useSWRInfinite<MixedResult>( const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
(pageIndex: number, previousPageData: MixedResult | null) => { (pageIndex: number, previousPageData: MixedResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
@@ -93,65 +90,50 @@ const MediaSlider = ({
return null; return null;
} }
const blacklistVisibility = hasPermission( const finalTitles = titles.slice(0, 20).map((title) => {
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], switch (title.mediaType) {
{ type: 'or' } case 'movie':
);
const finalTitles = titles
.slice(0, 20)
.filter((title) => {
if (!blacklistVisibility)
return ( return (
(title as TvResult | MovieResult).mediaInfo?.status !== <TitleCard
MediaStatus.BLACKLISTED key={title.id}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
/>
); );
return title; case 'tv':
}) return (
.map((title) => { <TitleCard
switch (title.mediaType) { key={title.id}
case 'movie': id={title.id}
return ( isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
<TitleCard image={title.posterPath}
key={title.id} status={title.mediaInfo?.status}
id={title.id} summary={title.overview}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} title={title.name}
image={title.posterPath} userScore={title.voteAverage}
status={title.mediaInfo?.status} year={title.firstAirDate}
summary={title.overview} mediaType={title.mediaType}
title={title.title} inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
userScore={title.voteAverage} />
year={title.releaseDate} );
mediaType={title.mediaType} case 'person':
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0} return (
/> <PersonCard
); personId={title.id}
case 'tv': name={title.name}
return ( profilePath={title.profilePath}
<TitleCard />
key={title.id} );
id={title.id} }
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} });
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={title.mediaType}
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
/>
);
case 'person':
return (
<PersonCard
personId={title.id}
name={title.name}
profilePath={title.profilePath}
/>
);
}
});
if (linkUrl && titles.length > 20) { if (linkUrl && titles.length > 20) {
finalTitles.push( finalTitles.push(

View File

@@ -3,9 +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 BlacklistModal from '@app/components/BlacklistModal';
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';
import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import LoadingSpinner from '@app/components/Common/LoadingSpinner';
@@ -27,7 +25,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 ErrorPage from '@app/pages/_error'; import Error 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';
@@ -36,7 +34,6 @@ import {
CloudIcon, CloudIcon,
CogIcon, CogIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon, FilmIcon,
PlayIcon, PlayIcon,
TicketIcon, TicketIcon,
@@ -44,22 +41,20 @@ 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, MediaType } from '@server/constants/media'; import { MediaStatus } 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 { useCallback, 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', {
@@ -99,12 +94,6 @@ 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 {
@@ -123,14 +112,7 @@ 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 [isUpdating, setIsUpdating] = useState<boolean>(false); const { publicRuntimeConfig } = getConfig();
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
!movie?.onUserWatchlist
);
const [isBlacklistUpdating, setIsBlacklistUpdating] =
useState<boolean>(false);
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
const { addToast } = useToasts();
const { const {
data, data,
@@ -160,11 +142,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
setShowManager(router.query.manage == '1' ? true : false); setShowManager(router.query.manage == '1' ? true : false);
}, [router.query.manage]); }, [router.query.manage]);
const closeBlacklistModal = useCallback(
() => setShowBlacklistModal(false),
[]
);
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
mediaUrl: data?.mediaInfo?.mediaUrl, mediaUrl: data?.mediaInfo?.mediaUrl,
mediaUrl4k: data?.mediaInfo?.mediaUrl4k, mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
@@ -177,7 +154,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
} }
if (!data) { if (!data) {
return <ErrorPage statusCode={404} />; return <Error statusCode={404} />;
} }
const showAllStudios = data.productionCompanies.length <= minStudios + 1; const showAllStudios = data.productionCompanies.length <= minStudios + 1;
@@ -287,7 +264,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
?.flatrate ?? []; ?.flatrate ?? [];
function getAvalaibleMediaServerName() { function getAvalaibleMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) { if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
} }
@@ -299,8 +276,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
} }
function getAvalaible4kMediaServerName() { function getAvalaible4kMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) { if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); return intl.formatMessage(messages.play4k, { mediaServerName: 'Emby' });
} }
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) { if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
@@ -310,134 +287,6 @@ 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);
}
};
const onClickHideItemBtn = async (): Promise<void> => {
setIsBlacklistUpdating(true);
const res = await fetch('/api/v1/blacklist', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
tmdbId: movie?.id,
mediaType: 'movie',
title: movie?.title,
user: user?.id,
}),
});
if (res.status === 201) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistSuccess, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
revalidate();
} else if (res.status === 412) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistDuplicateError, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
setIsBlacklistUpdating(false);
closeBlacklistModal();
};
const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], {
type: 'or',
});
return ( return (
<div <div
className="media-page" className="media-page"
@@ -448,7 +297,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
{data.backdropPath && ( {data.backdropPath && (
<div className="media-page-bg-image"> <div className="media-page-bg-image">
<CachedImage <CachedImage
type="tmdb"
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -484,18 +332,9 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
revalidate={() => revalidate()} revalidate={() => revalidate()}
show={showManager} show={showManager}
/> />
<BlacklistModal
tmdbId={data.id}
type="movie"
show={showBlacklistModal}
onCancel={closeBlacklistModal}
onComplete={onClickHideItemBtn}
isUpdating={isBlacklistUpdating}
/>
<div className="media-header"> <div className="media-header">
<div className="media-poster"> <div className="media-poster">
<CachedImage <CachedImage
type="tmdb"
src={ src={
data.posterPath data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -569,61 +408,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</span> </span>
</div> </div>
<div className="media-actions"> <div className="media-actions">
{showHideButton &&
data?.mediaInfo?.status !== MediaStatus.PROCESSING &&
data?.mediaInfo?.status !== MediaStatus.AVAILABLE &&
data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE &&
data?.mediaInfo?.status !== MediaStatus.PENDING &&
data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
<Tooltip
content={intl.formatMessage(globalMessages.addToBlacklist)}
>
<Button
buttonType={'ghost'}
className="z-40 mr-2"
buttonSize={'md'}
onClick={() => setShowBlacklistModal(true)}
>
<EyeSlashIcon className={'h-3'} />
</Button>
</Tooltip>
)}
{data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
<>
{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"
@@ -743,7 +527,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
<div className="group relative z-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-lg bg-gray-800 bg-cover bg-center shadow-md ring-1 ring-gray-700 transition duration-300 hover:scale-105 hover:ring-gray-500"> <div className="group relative z-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-lg bg-gray-800 bg-cover bg-center shadow-md ring-1 ring-gray-700 transition duration-300 hover:scale-105 hover:ring-gray-500">
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<CachedImage <CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`} src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
alt="" alt=""
style={{ style={{

View File

@@ -78,13 +78,6 @@ export const messages = defineMessages('components.PermissionEdit', {
viewwatchlists: 'View {mediaServerName} Watchlists', viewwatchlists: 'View {mediaServerName} Watchlists',
viewwatchlistsDescription: viewwatchlistsDescription:
"Grant permission to view other users' {mediaServerName} Watchlists.", "Grant permission to view other users' {mediaServerName} Watchlists.",
manageblacklist: 'Manage Blacklist',
manageblacklistDescription: 'Grant permission to manage blacklisted media.',
blacklistedItems: 'Blacklist media.',
blacklistedItemsDescription: 'Grant permission to blacklist media.',
viewblacklistedItems: 'View blacklisted media.',
viewblacklistedItemsDescription:
'Grant permission to view blacklisted media.',
}); });
interface PermissionEditProps { interface PermissionEditProps {
@@ -339,22 +332,6 @@ export const PermissionEdit = ({
}, },
], ],
}, },
{
id: 'manageblacklist',
name: intl.formatMessage(messages.manageblacklist),
description: intl.formatMessage(messages.manageblacklistDescription),
permission: Permission.MANAGE_BLACKLIST,
children: [
{
id: 'viewblacklisteditems',
name: intl.formatMessage(messages.viewblacklistedItems),
description: intl.formatMessage(
messages.viewblacklistedItemsDescription
),
permission: Permission.VIEW_BLACKLIST,
},
],
},
]; ];
return ( return (

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