Compare commits
6 Commits
v2.0.1
...
preview-je
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850fc7516b | ||
|
|
3aab8e94e3 | ||
|
|
3dafc362ed | ||
|
|
35342a945d | ||
|
|
186829a07e | ||
|
|
530b4dc638 |
@@ -439,15 +439,6 @@
|
||||
"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/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: 🐛 Bug Report
|
||||
description: Report a problem
|
||||
labels: ['bug', 'awaiting triage']
|
||||
labels: ['type:bug', 'awaiting-triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
2
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: ✨ Feature Request
|
||||
description: Suggest an idea
|
||||
labels: ['enhancement', 'awaiting triage']
|
||||
labels: ['type:enhancement', 'awaiting-triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
428
CHANGELOG.md
428
CHANGELOG.md
@@ -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)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<p align="center">
|
||||
<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="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>
|
||||
<!-- 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-47-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
**Jellyseerr** is a free and open source software application for managing requests for your media library.
|
||||
@@ -146,7 +146,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -22,7 +22,7 @@ export const VersionMismatchWarning = () => {
|
||||
<>
|
||||
{!isUpToDate ? (
|
||||
<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 type="success">
|
||||
@@ -95,12 +95,12 @@ export const VersionMatch = () => {
|
||||
};
|
||||
|
||||
offlineCache = pkgs.fetchYarnDeps {
|
||||
sha256 = pkgs.lib.fakeSha256;
|
||||
sha256 = pkgs.lib.fakeSha256;
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
}`;
|
||||
|
||||
|
||||
const module = `{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
@@ -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 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
|
||||
|
||||
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!
|
||||
|
||||
@@ -35,7 +35,7 @@ Users can override the [global display language](/using-jellyseerr/settings/gene
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -6,10 +6,15 @@ module.exports = {
|
||||
commitTag: process.env.COMMIT_TAG || 'local',
|
||||
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
|
||||
},
|
||||
publicRuntimeConfig: {
|
||||
// Will be available on both server and client
|
||||
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ hostname: 'gravatar.com' },
|
||||
{ hostname: 'image.tmdb.org' },
|
||||
{ hostname: '*', protocol: 'https' },
|
||||
],
|
||||
},
|
||||
webpack(config) {
|
||||
|
||||
@@ -38,8 +38,6 @@ tags:
|
||||
description: Endpoints related to getting service (Radarr/Sonarr) details.
|
||||
- name: watchlist
|
||||
description: Collection of media to watch later
|
||||
- name: blacklist
|
||||
description: Blacklisted media from discovery page.
|
||||
servers:
|
||||
- url: '{server}/api/v1'
|
||||
variables:
|
||||
@@ -48,19 +46,6 @@ servers:
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Blacklist:
|
||||
type: object
|
||||
properties:
|
||||
tmdbId:
|
||||
type: number
|
||||
example: 1
|
||||
title:
|
||||
type: string
|
||||
media:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
userId:
|
||||
type: number
|
||||
example: 1
|
||||
Watchlist:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2790,15 +2775,6 @@ paths:
|
||||
imageCount:
|
||||
type: number
|
||||
example: 123
|
||||
avatar:
|
||||
type: object
|
||||
properties:
|
||||
size:
|
||||
type: number
|
||||
example: 123456
|
||||
imageCount:
|
||||
type: number
|
||||
example: 123
|
||||
apiCaches:
|
||||
type: array
|
||||
items:
|
||||
@@ -3610,8 +3586,6 @@ paths:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
serverType:
|
||||
type: number
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
@@ -4066,94 +4040,6 @@ paths:
|
||||
restricted:
|
||||
type: boolean
|
||||
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:
|
||||
post:
|
||||
summary: Add media to watchlist
|
||||
@@ -4976,11 +4862,6 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
example: 8|9
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
example: 3|4
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellyseerr",
|
||||
"version": "2.0.1",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -62,7 +62,6 @@
|
||||
"formik": "^2.4.6",
|
||||
"gravatar-url": "3.1.0",
|
||||
"lodash": "4.17.21",
|
||||
"mime": "3",
|
||||
"next": "^14.2.4",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
@@ -120,7 +119,6 @@
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/mime": "3",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
|
||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -98,9 +98,6 @@ importers:
|
||||
lodash:
|
||||
specifier: 4.17.21
|
||||
version: 4.17.21
|
||||
mime:
|
||||
specifier: '3'
|
||||
version: 3.0.0
|
||||
next:
|
||||
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)
|
||||
@@ -267,9 +264,6 @@ importers:
|
||||
'@types/lodash':
|
||||
specifier: 4.14.191
|
||||
version: 4.14.191
|
||||
'@types/mime':
|
||||
specifier: '3'
|
||||
version: 3.0.4
|
||||
'@types/node':
|
||||
specifier: 20.14.8
|
||||
version: 20.14.8
|
||||
@@ -2854,9 +2848,6 @@ packages:
|
||||
'@types/mime@1.3.5':
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
|
||||
'@types/mime@3.0.4':
|
||||
resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==}
|
||||
|
||||
'@types/minimatch@3.0.5':
|
||||
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
|
||||
|
||||
@@ -10845,7 +10836,7 @@ snapshots:
|
||||
nopt: 5.0.0
|
||||
npmlog: 5.0.1
|
||||
rimraf: 3.0.2
|
||||
semver: 7.3.8
|
||||
semver: 7.6.2
|
||||
tar: 6.2.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
@@ -10920,13 +10911,13 @@ snapshots:
|
||||
'@npmcli/fs@1.1.1':
|
||||
dependencies:
|
||||
'@gar/promisify': 1.1.3
|
||||
semver: 7.3.8
|
||||
semver: 7.6.2
|
||||
optional: true
|
||||
|
||||
'@npmcli/fs@2.1.2':
|
||||
dependencies:
|
||||
'@gar/promisify': 1.1.3
|
||||
semver: 7.3.8
|
||||
semver: 7.6.2
|
||||
|
||||
'@npmcli/move-file@1.1.2':
|
||||
dependencies:
|
||||
@@ -12335,7 +12326,7 @@ snapshots:
|
||||
read-pkg: 5.2.0
|
||||
registry-auth-token: 5.0.2
|
||||
semantic-release: 19.0.5(encoding@0.1.13)
|
||||
semver: 7.3.8
|
||||
semver: 7.6.2
|
||||
tempy: 1.0.1
|
||||
|
||||
'@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@3.0.4': {}
|
||||
|
||||
'@types/minimatch@3.0.5': {}
|
||||
|
||||
'@types/minimist@1.2.5': {}
|
||||
@@ -12898,7 +12887,7 @@ snapshots:
|
||||
debug: 4.3.5(supports-color@8.1.1)
|
||||
globby: 11.1.0
|
||||
is-glob: 4.0.3
|
||||
semver: 7.3.8
|
||||
semver: 7.6.2
|
||||
tsutils: 3.21.0(typescript@4.9.5)
|
||||
optionalDependencies:
|
||||
typescript: 4.9.5
|
||||
@@ -17280,7 +17269,7 @@ snapshots:
|
||||
nopt: 5.0.0
|
||||
npmlog: 6.0.2
|
||||
rimraf: 3.0.2
|
||||
semver: 7.3.8
|
||||
semver: 7.6.2
|
||||
tar: 6.2.1
|
||||
which: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
@@ -17359,7 +17348,7 @@ snapshots:
|
||||
dependencies:
|
||||
hosted-git-info: 4.1.0
|
||||
is-core-module: 2.14.0
|
||||
semver: 7.3.8
|
||||
semver: 7.6.2
|
||||
validate-npm-package-license: 3.0.4
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
@@ -76,7 +76,7 @@ class ExternalAPI {
|
||||
}
|
||||
const data = await this.getDataFromResponse(response);
|
||||
|
||||
if (this.cache && ttl !== 0) {
|
||||
if (this.cache) {
|
||||
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class ExternalAPI {
|
||||
}
|
||||
const resData = await this.getDataFromResponse(response);
|
||||
|
||||
if (this.cache && ttl !== 0) {
|
||||
if (this.cache) {
|
||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ class ExternalAPI {
|
||||
}
|
||||
const resData = await this.getDataFromResponse(response);
|
||||
|
||||
if (this.cache && ttl !== 0) {
|
||||
if (this.cache) {
|
||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ class RottenTomatoes extends ExternalAPI {
|
||||
);
|
||||
}
|
||||
|
||||
if (!tvshow || !tvshow.rottenTomatoes) {
|
||||
if (!tvshow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,13 +157,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
|
||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||
try {
|
||||
const data = await this.get<QueueResponse<QueueItemAppendT>>(
|
||||
`/queue`,
|
||||
{
|
||||
includeEpisode: 'true',
|
||||
},
|
||||
0
|
||||
);
|
||||
const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
|
||||
includeEpisode: 'true',
|
||||
});
|
||||
|
||||
return data.records;
|
||||
} catch (e) {
|
||||
@@ -197,24 +193,15 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
}
|
||||
};
|
||||
|
||||
async refreshMonitoredDownloads(): Promise<void> {
|
||||
await this.runCommand('RefreshMonitoredDownloads', {});
|
||||
}
|
||||
|
||||
protected async runCommand(
|
||||
commandName: string,
|
||||
options: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.post(
|
||||
`/command`,
|
||||
{
|
||||
name: commandName,
|
||||
...options,
|
||||
},
|
||||
{},
|
||||
0
|
||||
);
|
||||
await this.post(`/command`, {
|
||||
name: commandName,
|
||||
...options,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
||||
}
|
||||
|
||||
@@ -303,10 +303,10 @@ class SonarrAPI extends ServarrBase<{
|
||||
});
|
||||
|
||||
try {
|
||||
await this.runCommand('MissingEpisodeSearch', { seriesId });
|
||||
await this.runCommand('SeriesSearch', { seriesId });
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong while executing Sonarr missing episode search.',
|
||||
'Something went wrong while executing Sonarr series search.',
|
||||
{
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
|
||||
@@ -95,7 +95,6 @@ interface DiscoverTvOptions {
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
|
||||
}
|
||||
|
||||
class TheMovieDb extends ExternalAPI {
|
||||
@@ -524,7 +523,6 @@ class TheMovieDb extends ExternalAPI {
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
withStatus,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
const defaultFutureDate = new Date(
|
||||
@@ -572,7 +570,6 @@ class TheMovieDb extends ExternalAPI {
|
||||
'vote_count.lte': voteCountLte || '',
|
||||
with_watch_providers: watchProviders || '',
|
||||
watch_region: watchRegion || '',
|
||||
with_status: withStatus || '',
|
||||
});
|
||||
|
||||
return data;
|
||||
|
||||
@@ -2,7 +2,6 @@ export enum ApiErrorCode {
|
||||
InvalidUrl = 'INVALID_URL',
|
||||
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
||||
InvalidEmail = 'INVALID_EMAIL',
|
||||
NotAdmin = 'NOT_ADMIN',
|
||||
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||
|
||||
@@ -16,5 +16,4 @@ export enum MediaStatus {
|
||||
PROCESSING,
|
||||
PARTIALLY_AVAILABLE,
|
||||
AVAILABLE,
|
||||
BLACKLISTED,
|
||||
}
|
||||
|
||||
@@ -4,8 +4,3 @@ export enum MediaServerType {
|
||||
EMBY,
|
||||
NOT_CONFIGURED,
|
||||
}
|
||||
|
||||
export enum ServerType {
|
||||
JELLYFIN = 'Jellyfin',
|
||||
EMBY = 'Emby',
|
||||
}
|
||||
|
||||
@@ -2,5 +2,4 @@ export enum UserType {
|
||||
PLEX = 1,
|
||||
LOCAL = 2,
|
||||
JELLYFIN = 3,
|
||||
EMBY = 4,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { Blacklist } from '@server/entity/Blacklist';
|
||||
import type { User } from '@server/entity/User';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
Entity,
|
||||
Index,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
@@ -68,7 +66,7 @@ class Media {
|
||||
|
||||
try {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: id, mediaType: mediaType },
|
||||
where: { tmdbId: id, mediaType },
|
||||
relations: { requests: true, issues: true },
|
||||
});
|
||||
|
||||
@@ -118,11 +116,6 @@ class Media {
|
||||
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||
public issues: Issue[];
|
||||
|
||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
|
||||
eager: true,
|
||||
})
|
||||
public blacklist: Blacklist;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@@ -218,10 +211,9 @@ class Media {
|
||||
}
|
||||
} else {
|
||||
const pageName =
|
||||
getSettings().main.mediaServerType == MediaServerType.EMBY
|
||||
? 'item'
|
||||
: 'details';
|
||||
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
||||
const { serverId, externalHostname } = getSettings().jellyfin;
|
||||
|
||||
const jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
@@ -231,7 +223,7 @@ class Media {
|
||||
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ export class RequestPermissionError extends Error {}
|
||||
export class QuotaRestrictedError extends Error {}
|
||||
export class DuplicateMediaRequestError extends Error {}
|
||||
export class NoSeasonsAvailableError extends Error {}
|
||||
export class BlacklistedMediaError extends Error {}
|
||||
|
||||
type MediaRequestOptions = {
|
||||
isAutoRequest?: boolean;
|
||||
@@ -144,16 +143,6 @@ export class MediaRequest {
|
||||
mediaType: requestBody.mediaType,
|
||||
});
|
||||
} 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) {
|
||||
media.status = MediaStatus.PENDING;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import clearCookies from '@server/middleware/clearcookies';
|
||||
import routes from '@server/routes';
|
||||
import avatarproxy from '@server/routes/avatarproxy';
|
||||
import imageproxy from '@server/routes/imageproxy';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
@@ -175,7 +174,7 @@ app
|
||||
},
|
||||
store: new TypeormStore({
|
||||
cleanupLimit: 2,
|
||||
ttl: 60 * 60 * 24 * 30,
|
||||
ttl: 1000 * 60 * 60 * 24 * 30,
|
||||
}).connect(sessionRespository) as Store,
|
||||
})
|
||||
);
|
||||
@@ -203,7 +202,6 @@ app
|
||||
|
||||
// Do not set cookies so CDNs can cache them
|
||||
server.use('/imageproxy', clearCookies, imageproxy);
|
||||
server.use('/avatarproxy', clearCookies, avatarproxy);
|
||||
|
||||
server.get('*', (req, res) => handle(req, res));
|
||||
server.use(
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export interface CacheItem {
|
||||
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
||||
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
|
||||
@@ -227,9 +227,6 @@ export const startJobs = (): void => {
|
||||
});
|
||||
// Clean TMDB image cache
|
||||
ImageProxy.clearCache('tmdb');
|
||||
|
||||
// Clean users avatar image cache
|
||||
ImageProxy.clearCache('avatar');
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface DownloadingItem {
|
||||
timeLeft: string;
|
||||
estimatedCompletionTime: Date;
|
||||
title: string;
|
||||
downloadId: string;
|
||||
episode?: EpisodeNumberResult;
|
||||
}
|
||||
|
||||
@@ -85,7 +84,6 @@ class DownloadTracker {
|
||||
});
|
||||
|
||||
try {
|
||||
await radarr.refreshMonitoredDownloads();
|
||||
const queueItems = await radarr.getQueue();
|
||||
|
||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||
@@ -97,7 +95,6 @@ class DownloadTracker {
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
downloadId: item.downloadId,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
@@ -163,7 +160,6 @@ class DownloadTracker {
|
||||
});
|
||||
|
||||
try {
|
||||
await sonarr.refreshMonitoredDownloads();
|
||||
const queueItems = await sonarr.getQueue();
|
||||
|
||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||
@@ -176,7 +172,6 @@ class DownloadTracker {
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
episode: item.episode,
|
||||
downloadId: item.downloadId,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { RateLimitOptions } from '@server/utils/rateLimit';
|
||||
import rateLimit from '@server/utils/rateLimit';
|
||||
import { createHash } from 'crypto';
|
||||
import { promises } from 'fs';
|
||||
import mime from 'mime/lite';
|
||||
import path, { join } from 'path';
|
||||
|
||||
type ImageResponse = {
|
||||
@@ -12,7 +11,7 @@ type ImageResponse = {
|
||||
curRevalidate: number;
|
||||
isStale: boolean;
|
||||
etag: string;
|
||||
extension: string | null;
|
||||
extension: string;
|
||||
cacheKey: string;
|
||||
cacheMiss: boolean;
|
||||
};
|
||||
@@ -28,45 +27,29 @@ class ImageProxy {
|
||||
let deletedImages = 0;
|
||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||
|
||||
try {
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const stat = await promises.lstat(filePath);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const stat = await promises.lstat(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const imageFiles = await promises.readdir(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
const imageFiles = await promises.readdir(filePath);
|
||||
|
||||
for (const imageFile of imageFiles) {
|
||||
const [, expireAtSt] = imageFile.split('.');
|
||||
const expireAt = Number(expireAtSt);
|
||||
const now = Date.now();
|
||||
for (const imageFile of imageFiles) {
|
||||
const [, expireAtSt] = imageFile.split('.');
|
||||
const expireAt = Number(expireAtSt);
|
||||
const now = Date.now();
|
||||
|
||||
if (now > expireAt) {
|
||||
await promises.rm(path.join(filePath), {
|
||||
recursive: true,
|
||||
});
|
||||
deletedImages += 1;
|
||||
}
|
||||
if (now > expireAt) {
|
||||
await promises.rm(path.join(filePath, imageFile));
|
||||
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',
|
||||
});
|
||||
}
|
||||
@@ -86,49 +69,33 @@ class ImageProxy {
|
||||
}
|
||||
|
||||
private static async getDirectorySize(dir: string): Promise<number> {
|
||||
try {
|
||||
const files = await promises.readdir(dir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const files = await promises.readdir(dir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
const paths = files.map(async (file) => {
|
||||
const path = join(dir, file.name);
|
||||
const paths = files.map(async (file) => {
|
||||
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()) {
|
||||
const { size } = await promises.stat(path);
|
||||
if (file.isFile()) {
|
||||
const { size } = await promises.stat(path);
|
||||
|
||||
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 size;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (await Promise.all(paths))
|
||||
.flat(Infinity)
|
||||
.reduce((i, size) => i + size, 0);
|
||||
}
|
||||
|
||||
private static async getImageCount(dir: string) {
|
||||
try {
|
||||
const files = await promises.readdir(dir);
|
||||
const files = await promises.readdir(dir);
|
||||
|
||||
return files.length;
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
return files.length;
|
||||
}
|
||||
|
||||
private fetch: typeof fetch;
|
||||
@@ -180,27 +147,6 @@ class ImageProxy {
|
||||
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> {
|
||||
try {
|
||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||
@@ -241,25 +187,16 @@ class ImageProxy {
|
||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||
const href =
|
||||
this.baseUrl +
|
||||
(this.baseUrl.length > 0
|
||||
? this.baseUrl.endsWith('/')
|
||||
? ''
|
||||
: '/'
|
||||
: '') +
|
||||
(this.baseUrl.endsWith('/') ? '' : '/') +
|
||||
(path.startsWith('/') ? path.slice(1) : path);
|
||||
const response = await this.fetch(href);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const extension = mime.getExtension(
|
||||
response.headers.get('content-type') ?? ''
|
||||
);
|
||||
|
||||
let maxAge = Number(
|
||||
const extension = path.split('.').pop() ?? '';
|
||||
const maxAge = Number(
|
||||
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
||||
);
|
||||
|
||||
if (!maxAge) maxAge = 86400;
|
||||
const expireAt = Date.now() + maxAge * 1000;
|
||||
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
||||
|
||||
@@ -295,7 +232,7 @@ class ImageProxy {
|
||||
|
||||
private async writeToCacheDir(
|
||||
dir: string,
|
||||
extension: string | null,
|
||||
extension: string,
|
||||
maxAge: number,
|
||||
expireAt: number,
|
||||
buffer: Buffer,
|
||||
|
||||
@@ -27,8 +27,6 @@ export enum Permission {
|
||||
AUTO_REQUEST_TV = 33554432,
|
||||
RECENT_VIEW = 67108864,
|
||||
WATCHLIST_VIEW = 134217728,
|
||||
MANAGE_BLACKLIST = 268435456,
|
||||
VIEW_BLACKLIST = 1073741824,
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
|
||||
@@ -567,10 +567,7 @@ class JellyfinScanner {
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType != MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType != MediaServerType.EMBY
|
||||
) {
|
||||
if (settings.main.mediaServerType != MediaServerType.JELLYFIN) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -611,11 +611,7 @@ class Settings {
|
||||
}
|
||||
|
||||
private generateApiKey(): string {
|
||||
if (process.env.API_KEY) {
|
||||
return process.env.API_KEY;
|
||||
} else {
|
||||
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
||||
}
|
||||
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
||||
}
|
||||
|
||||
private generateVapidKeys(force = false): void {
|
||||
@@ -652,12 +648,6 @@ class Settings {
|
||||
|
||||
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();
|
||||
}
|
||||
return this;
|
||||
|
||||
@@ -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;
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,6 @@ export interface MovieDetails {
|
||||
mediaUrl?: string;
|
||||
watchProviders?: WatchProviders[];
|
||||
keywords: Keyword[];
|
||||
onUserWatchlist?: boolean;
|
||||
}
|
||||
|
||||
export const mapProductionCompany = (
|
||||
@@ -102,8 +101,7 @@ export const mapProductionCompany = (
|
||||
|
||||
export const mapMovieDetails = (
|
||||
movie: TmdbMovieDetails,
|
||||
media?: Media,
|
||||
userWatchlist?: boolean
|
||||
media?: Media
|
||||
): MovieDetails => ({
|
||||
id: movie.id,
|
||||
adult: movie.adult,
|
||||
@@ -150,5 +148,4 @@ export const mapMovieDetails = (
|
||||
id: keyword.id,
|
||||
name: keyword.name,
|
||||
})),
|
||||
onUserWatchlist: userWatchlist,
|
||||
});
|
||||
|
||||
@@ -111,7 +111,6 @@ export interface TvDetails {
|
||||
keywords: Keyword[];
|
||||
mediaInfo?: Media;
|
||||
watchProviders?: WatchProviders[];
|
||||
onUserWatchlist?: boolean;
|
||||
}
|
||||
|
||||
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
|
||||
@@ -162,8 +161,7 @@ export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({
|
||||
|
||||
export const mapTvDetails = (
|
||||
show: TmdbTvDetails,
|
||||
media?: Media,
|
||||
userWatchlist?: boolean
|
||||
media?: Media
|
||||
): TvDetails => ({
|
||||
createdBy: show.created_by,
|
||||
episodeRunTime: show.episode_run_time,
|
||||
@@ -225,5 +223,4 @@ export const mapTvDetails = (
|
||||
})),
|
||||
mediaInfo: media,
|
||||
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
|
||||
onUserWatchlist: userWatchlist,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
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 { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import { startJobs } from '@server/job/schedule';
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
@@ -228,20 +227,15 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
urlBase?: string;
|
||||
useSsl?: boolean;
|
||||
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 (
|
||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||
settings.jellyfin.ip !== ''
|
||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
||||
) {
|
||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||
}
|
||||
|
||||
if (!body.username) {
|
||||
} else if (!body.username) {
|
||||
return res.status(500).json({ error: 'You must provide an username' });
|
||||
} else if (settings.jellyfin.ip !== '' && body.hostname) {
|
||||
return res
|
||||
@@ -262,6 +256,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
urlBase: body.urlBase,
|
||||
});
|
||||
|
||||
const { externalHostname } = getSettings().jellyfin;
|
||||
|
||||
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
||||
let user = await userRepository.findOne({
|
||||
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
|
||||
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;
|
||||
let clientIp;
|
||||
@@ -317,49 +317,22 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
);
|
||||
|
||||
// User doesn't exist, and there are no users in the database, we'll create the user
|
||||
// with admin permissions
|
||||
switch (body.serverType) {
|
||||
case MediaServerType.EMBY:
|
||||
settings.main.mediaServerType = MediaServerType.EMBY;
|
||||
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.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');
|
||||
}
|
||||
// with admin permission
|
||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
||||
user = new User({
|
||||
email: body.email || account.User.Name,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
permissions: Permission.ADMIN,
|
||||
avatar: account.User.PrimaryImageTag
|
||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||
: gravatarUrl(body.email || account.User.Name, {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
}),
|
||||
userType: UserType.JELLYFIN,
|
||||
});
|
||||
|
||||
// Create an API key on Jellyfin from this admin user
|
||||
const jellyfinClient = new JellyfinAPI(
|
||||
@@ -388,12 +361,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
logger.info(
|
||||
`Found matching ${
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? ServerType.JELLYFIN
|
||||
: ServerType.EMBY
|
||||
? 'Jellyfin'
|
||||
: 'Emby'
|
||||
} user; updating user with ${
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? ServerType.JELLYFIN
|
||||
: ServerType.EMBY
|
||||
? 'Jellyfin'
|
||||
: 'Emby'
|
||||
}`,
|
||||
{
|
||||
label: 'API',
|
||||
@@ -403,24 +376,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
);
|
||||
// Update the users avatar with their jellyfin profile pic (incase it changed)
|
||||
if (account.User.PrimaryImageTag) {
|
||||
const avatar = `/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;
|
||||
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
||||
} else {
|
||||
const avatar = gravatarUrl(user.email || account.User.Name, {
|
||||
user.avatar = gravatarUrl(user.email || account.User.Name, {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
});
|
||||
|
||||
if (avatar !== user.avatar) {
|
||||
const avatarProxy = new ImageProxy('avatar', '');
|
||||
avatarProxy.clearCachedImage(user.avatar);
|
||||
}
|
||||
|
||||
user.avatar = avatar;
|
||||
}
|
||||
user.jellyfinUsername = account.User.Name;
|
||||
|
||||
@@ -428,6 +389,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
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);
|
||||
} else if (!settings.main.newPlexLogin) {
|
||||
logger.warn(
|
||||
@@ -460,17 +427,13 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
jellyfinDeviceId: deviceId,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
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, {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
}),
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
: UserType.EMBY,
|
||||
userType: UserType.JELLYFIN,
|
||||
});
|
||||
|
||||
//initialize Jellyfin/Emby users with local login
|
||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||
if (passedExplicitPassword) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -71,7 +71,6 @@ const QueryFilterOptions = z.object({
|
||||
network: z.coerce.string().optional(),
|
||||
watchProviders: z.coerce.string().optional(),
|
||||
watchRegion: z.coerce.string().optional(),
|
||||
status: z.coerce.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
@@ -386,7 +385,6 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
voteCountLte: query.voteCountLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
withStatus: query.status,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
|
||||
@@ -23,7 +23,6 @@ import restartFlag from '@server/utils/restartFlag';
|
||||
import { isPerson } from '@server/utils/typeHelpers';
|
||||
import { Router } from 'express';
|
||||
import authRoutes from './auth';
|
||||
import blacklistRoutes from './blacklist';
|
||||
import collectionRoutes from './collection';
|
||||
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
||||
import issueRoutes from './issue';
|
||||
@@ -145,7 +144,6 @@ router.use('/search', isAuthenticated(), searchRoutes);
|
||||
router.use('/discover', isAuthenticated(), discoverRoutes);
|
||||
router.use('/request', isAuthenticated(), requestRoutes);
|
||||
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
|
||||
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
|
||||
router.use('/movie', isAuthenticated(), movieRoutes);
|
||||
router.use('/tv', isAuthenticated(), tvRoutes);
|
||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||
|
||||
@@ -3,9 +3,7 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import { type RatingResponse } from '@server/api/ratings';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import logger from '@server/logger';
|
||||
import { mapMovieDetails } from '@server/models/Movie';
|
||||
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 onUserWatchlist = await getRepository(Watchlist).exist({
|
||||
where: {
|
||||
tmdbId: Number(req.params.id),
|
||||
requestedBy: {
|
||||
id: req.user?.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const data = mapMovieDetails(tmdbMovie, media, onUserWatchlist);
|
||||
|
||||
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||
if (!data.overview) {
|
||||
const tvEnglish = await tmdb.getMovie({ movieId: Number(req.params.id) });
|
||||
data.overview = tvEnglish.overview;
|
||||
}
|
||||
|
||||
return res.status(200).json(data);
|
||||
return res.status(200).json(mapMovieDetails(tmdbMovie, media));
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving movie', {
|
||||
label: 'API',
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import {
|
||||
BlacklistedMediaError,
|
||||
DuplicateMediaRequestError,
|
||||
MediaRequest,
|
||||
NoSeasonsAvailableError,
|
||||
@@ -244,8 +243,6 @@ requestRoutes.post<never, MediaRequest, MediaRequestBody>(
|
||||
return next({ status: 409, message: error.message });
|
||||
case NoSeasonsAvailableError:
|
||||
return next({ status: 202, message: error.message });
|
||||
case BlacklistedMediaError:
|
||||
return next({ status: 403, message: error.message });
|
||||
default:
|
||||
return next({ status: 500, message: error.message });
|
||||
}
|
||||
|
||||
@@ -377,6 +377,11 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
||||
|
||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const { externalHostname } = settings.jellyfin;
|
||||
const jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
: getHostname();
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
@@ -396,7 +401,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||
username: user.Name,
|
||||
id: user.Id,
|
||||
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 }),
|
||||
email: user.Name,
|
||||
}));
|
||||
@@ -741,13 +746,11 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
||||
}));
|
||||
|
||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
||||
|
||||
return res.status(200).json({
|
||||
apiCaches,
|
||||
imageCache: {
|
||||
tmdb: tmdbImageCache,
|
||||
avatar: avatarImageCache,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import logger from '@server/logger';
|
||||
import { mapTvResult } from '@server/models/Search';
|
||||
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 onUserWatchlist = await getRepository(Watchlist).exist({
|
||||
where: {
|
||||
tmdbId: Number(req.params.id),
|
||||
requestedBy: {
|
||||
id: req.user?.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const data = mapTvDetails(tv, media, onUserWatchlist);
|
||||
|
||||
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||
if (!data.overview) {
|
||||
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
|
||||
data.overview = tvEnglish.overview;
|
||||
}
|
||||
|
||||
return res.status(200).json(data);
|
||||
return res.status(200).json(mapTvDetails(tv, media));
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving series', {
|
||||
label: 'API',
|
||||
|
||||
@@ -2,7 +2,6 @@ import JellyfinAPI from '@server/api/jellyfin';
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import TautulliAPI from '@server/api/tautulli';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -516,6 +515,12 @@ router.post(
|
||||
|
||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||
const createdUsers: User[] = [];
|
||||
const { externalHostname } = getSettings().jellyfin;
|
||||
|
||||
const jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||
const jellyfinUsers = await jellyfinClient.getUsers();
|
||||
@@ -540,15 +545,12 @@ router.post(
|
||||
email: jellyfinUser?.Name,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
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 ?? '', {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
}),
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
: UserType.EMBY,
|
||||
userType: UserType.JELLYFIN,
|
||||
});
|
||||
|
||||
await userRepository.save(newUser);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserSettings } from '@server/entity/UserSettings';
|
||||
@@ -10,7 +9,6 @@ import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { Router } from 'express';
|
||||
import { canMakePermissionsChange } from '.';
|
||||
|
||||
@@ -100,18 +98,10 @@ userSettingsRoutes.post<
|
||||
}
|
||||
|
||||
user.username = req.body.username;
|
||||
const oldEmail = user.email;
|
||||
if (user.jellyfinUsername) {
|
||||
user.email = req.body.email || user.jellyfinUsername || user.email;
|
||||
}
|
||||
|
||||
const existingUser = await userRepository.findOne({
|
||||
where: { email: user.email },
|
||||
});
|
||||
if (oldEmail !== user.email && existingUser) {
|
||||
throw new ApiError(400, ApiErrorCode.InvalidEmail);
|
||||
}
|
||||
|
||||
// Update quota values only if the user has the correct permissions
|
||||
if (
|
||||
!user.hasPermission(Permission.MANAGE_USERS) &&
|
||||
@@ -155,14 +145,7 @@ userSettingsRoutes.post<
|
||||
email: savedUser.email,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.errorCode) {
|
||||
return next({
|
||||
status: e.statusCode,
|
||||
message: e.errorCode,
|
||||
});
|
||||
} else {
|
||||
return next({ status: 500, message: e.message });
|
||||
}
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
y="0px" width="100%" viewBox="0 0 617 188" enable-background="new 0 0 617 188" xml:space="preserve">
|
||||
<path fill="#52B54B" opacity="1.000000" stroke="none" d="
|
||||
M89.583336,1.000000
|
||||
C90.189529,1.685005 90.166168,2.574803 90.599510,3.025271
|
||||
C103.718315,16.662701 116.882103,30.256845 129.948212,43.764053
|
||||
C130.577850,43.523941 130.916519,43.491173 131.111343,43.306595
|
||||
C138.657471,36.157455 138.655273,36.156090 146.005478,43.505203
|
||||
C159.538589,57.036308 173.016449,70.623535 186.654617,84.047913
|
||||
C189.264145,86.616562 189.414017,88.253456 186.716782,90.895164
|
||||
C174.709808,102.655037 162.893280,114.609337 151.008514,126.493958
|
||||
C146.073502,131.428925 146.076691,131.427155 151.017944,136.523712
|
||||
C151.698944,137.226120 152.340485,137.966812 153.259171,138.973434
|
||||
C151.947098,140.380035 150.766312,141.712204 149.516266,142.975861
|
||||
C134.544815,158.110641 119.563087,173.235260 104.792023,188.681274
|
||||
C103.611107,189.000000 102.222221,189.000000 100.624634,188.681274
|
||||
C86.361732,174.796494 72.307518,161.230438 57.702755,147.132965
|
||||
C56.157101,149.136856 54.135899,151.757263 51.994804,154.533112
|
||||
C35.932781,138.457108 20.569420,123.048477 5.141897,107.704361
|
||||
C3.997114,106.565773 2.391420,105.890610 1.000000,105.000000
|
||||
C1.000000,103.611107 1.000000,102.222221 1.318741,100.624641
|
||||
C15.203506,86.361694 28.769531,72.307434 42.867004,57.702602
|
||||
C40.863205,56.156994 38.242813,54.135792 35.425343,51.962570
|
||||
C51.518696,35.908516 66.939468,20.557360 82.295547,5.141749
|
||||
C83.434830,3.998048 84.109390,2.391417 85.000000,0.999999
|
||||
C86.388893,1.000000 87.777779,1.000000 89.583336,1.000000
|
||||
M73.196465,79.500702
|
||||
C73.196465,96.254150 73.196465,113.007599 73.196465,130.872055
|
||||
C94.273178,118.764557 114.417175,107.192863 135.221664,95.241745
|
||||
C114.247169,83.251732 94.091187,71.729622 73.196594,59.785294
|
||||
C73.196594,66.631348 73.196594,72.566254 73.196465,79.500702
|
||||
z" />
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
||||
M618.000000,60.571537
|
||||
C617.004395,62.042580 615.613281,62.912964 615.073181,64.153824
|
||||
C608.143372,80.073746 601.328613,96.043816 594.498169,112.006920
|
||||
C586.973572,129.592300 579.343018,147.133865 571.999390,164.794601
|
||||
C568.632385,172.892075 568.893372,173.002594 560.133972,172.999832
|
||||
C555.470825,172.998367 550.807617,172.994385 546.144592,172.969360
|
||||
C545.841980,172.967712 545.540466,172.775543 544.836609,172.534256
|
||||
C548.592896,163.531219 551.714905,154.222061 556.286133,145.689255
|
||||
C559.733765,139.253830 559.138794,134.062668 556.454224,127.695969
|
||||
C546.360352,103.757523 536.803345,79.592712 526.837830,55.000847
|
||||
C534.817078,55.000847 542.437622,54.725182 550.003540,55.244331
|
||||
C551.436218,55.342628 553.169678,58.412052 553.885010,60.423309
|
||||
C558.720520,74.018005 563.307556,87.700912 568.003784,101.345413
|
||||
C569.107483,104.551987 570.321045,107.720764 571.976196,112.255157
|
||||
C573.889587,107.365631 575.415283,103.375916 577.007935,99.413109
|
||||
C582.693298,85.266724 588.344238,71.105591 594.218018,57.037624
|
||||
C594.650513,56.001743 596.734497,55.132927 598.079773,55.089733
|
||||
C604.401855,54.886726 610.734131,54.999401 617.531372,54.999699
|
||||
C618.000000,56.714359 618.000000,58.428715 618.000000,60.571537
|
||||
z" />
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
||||
M430.000122,99.002235
|
||||
C430.000122,112.477097 430.000122,125.452438 430.000122,138.713440
|
||||
C423.048126,138.713440 416.308685,138.713440 408.999878,138.713440
|
||||
C408.999878,129.350739 409.120758,119.916939 408.962219,110.487823
|
||||
C408.832153,102.753624 409.088898,94.909142 407.866791,87.324188
|
||||
C406.440887,78.474220 401.302399,74.201607 394.304291,74.000290
|
||||
C387.617249,73.807938 380.317963,79.297188 378.047363,86.438652
|
||||
C377.420715,88.409592 377.055725,90.550858 377.044647,92.616508
|
||||
C376.962494,107.913475 377.000122,123.211082 377.000122,138.753479
|
||||
C369.630646,138.753479 362.559692,138.753479 354.999878,138.753479
|
||||
C354.999878,123.256836 355.044769,107.816956 354.977661,92.377571
|
||||
C354.951050,86.251518 352.748199,80.799278 347.911346,77.066116
|
||||
C339.239685,70.373154 327.811401,74.635170 324.084412,84.471092
|
||||
C322.793915,87.876816 322.147491,91.713402 322.090881,95.366882
|
||||
C321.868958,109.685005 322.000122,124.008591 322.000122,138.665009
|
||||
C314.823853,138.665009 307.760773,138.665009 300.346558,138.665009
|
||||
C300.346558,111.006645 300.346558,83.281189 300.346558,55.001301
|
||||
C306.163818,55.001301 312.104645,54.855133 318.024780,55.139343
|
||||
C319.060455,55.189068 320.450378,56.891682 320.882477,58.112110
|
||||
C321.380768,59.519447 320.998291,61.238617 320.998291,64.136040
|
||||
C328.715179,54.407440 338.407898,52.804527 348.408875,54.206123
|
||||
C356.403381,55.326527 361.770447,57.638248 366.682190,66.544373
|
||||
C372.325470,62.972542 377.601440,58.269657 383.771973,56.014080
|
||||
C396.273407,51.444298 408.602570,53.673611 419.067657,61.818150
|
||||
C426.629364,67.703125 429.037811,76.770744 429.932556,86.011482
|
||||
C430.332214,90.138710 430.000122,94.336792 430.000122,99.002235
|
||||
z" />
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
||||
M462.000427,35.006332
|
||||
C462.000427,44.815434 462.000427,54.126144 462.000427,64.132019
|
||||
C468.844696,58.319965 476.100769,54.654530 484.669739,53.656227
|
||||
C496.686127,52.256294 507.565582,54.979622 516.927185,62.503853
|
||||
C534.236755,76.416115 535.360107,106.231667 523.651062,123.341644
|
||||
C516.745056,133.433182 506.539673,139.485458 493.555267,140.111023
|
||||
C483.836304,140.579254 474.670624,139.889420 466.610413,133.799713
|
||||
C465.039795,132.613068 463.390686,131.530289 461.957214,130.525391
|
||||
C461.633789,132.375305 461.105469,135.397171 460.522095,138.733841
|
||||
C454.446686,138.733841 448.017822,138.733841 441.292542,138.733841
|
||||
C441.292542,99.722672 441.292542,60.652122 441.292542,21.290209
|
||||
C447.943787,21.290209 454.684204,21.290209 462.000427,21.290209
|
||||
C462.000427,25.636984 462.000427,30.072460 462.000427,35.006332
|
||||
M480.890228,119.974937
|
||||
C485.426086,119.681152 490.365997,120.444260 494.421356,118.893707
|
||||
C506.182587,114.396866 510.858643,104.919495 509.036591,92.234833
|
||||
C507.422546,80.997993 496.539307,71.772278 483.551605,73.864754
|
||||
C469.724976,76.092384 464.376770,85.538391 463.152863,96.752327
|
||||
C462.120667,106.209480 469.961761,116.189537 480.890228,119.974937
|
||||
z" />
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
||||
M234.797928,54.654831
|
||||
C244.856339,52.605957 254.504562,52.040043 264.239868,54.923946
|
||||
C279.600891,59.474377 286.402191,68.163963 289.768585,81.937614
|
||||
C291.530579,89.146889 290.954620,96.927589 291.469940,105.005005
|
||||
C269.550385,105.005005 248.375092,105.005005 227.094437,105.005005
|
||||
C229.577957,116.288628 239.741562,120.764336 248.594757,121.034813
|
||||
C256.790771,121.285217 264.390472,119.882645 271.081848,114.731178
|
||||
C271.774902,114.197632 273.962708,114.659111 274.786041,115.402222
|
||||
C278.726318,118.958458 282.435333,122.770882 286.509888,126.770363
|
||||
C281.309174,132.968170 274.787445,135.946014 267.542938,138.175064
|
||||
C253.746231,142.420120 240.209259,142.317459 227.237503,135.935410
|
||||
C212.712891,128.789368 205.730453,116.523628 204.973831,100.473404
|
||||
C204.537735,91.222557 205.503754,82.283119 210.008469,74.017265
|
||||
C215.396210,64.131088 223.372589,57.511646 234.797928,54.654831
|
||||
M266.971497,78.708908
|
||||
C259.384399,70.789909 249.920425,70.480316 240.489548,73.410858
|
||||
C234.405487,75.301414 229.437546,79.631561 227.800247,86.722244
|
||||
C242.152313,86.722244 256.002747,86.722244 270.947815,86.722244
|
||||
C269.410950,83.870155 268.228943,81.676651 266.971497,78.708908
|
||||
z" />
|
||||
<path fill="#FCFEFC" opacity="1.000000" stroke="none" d="
|
||||
M73.196533,79.000931
|
||||
C73.196594,72.566254 73.196594,66.631348 73.196594,59.785294
|
||||
C94.091187,71.729622 114.247169,83.251732 135.221664,95.241745
|
||||
C114.417175,107.192863 94.273178,118.764557 73.196465,130.872055
|
||||
C73.196465,113.007599 73.196465,96.254150 73.196533,79.000931
|
||||
z" />
|
||||
</svg>
|
||||
<?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: 7.8 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -183,11 +183,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const blacklistVisibility = hasPermission(
|
||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
||||
{ type: 'or' }
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="media-page"
|
||||
@@ -198,7 +193,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
{data.backdropPath && (
|
||||
<div className="media-page-bg-image">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -229,7 +223,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
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"
|
||||
isLoading={false}
|
||||
isEmpty={data.parts.length === 0}
|
||||
items={data.parts
|
||||
.filter((title) => {
|
||||
if (!blacklistVisibility)
|
||||
return title.mediaInfo?.status !== MediaStatus.BLACKLISTED;
|
||||
return title;
|
||||
})
|
||||
.map((title) => (
|
||||
<TitleCard
|
||||
key={`collection-movie-${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}
|
||||
/>
|
||||
))}
|
||||
items={data.parts.map((title) => (
|
||||
<TitleCard
|
||||
key={`collection-movie-${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}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="extra-bottom-space relative" />
|
||||
</div>
|
||||
|
||||
@@ -4,34 +4,21 @@ import Image from 'next/image';
|
||||
|
||||
const imageLoader: ImageLoader = ({ src }) => src;
|
||||
|
||||
export type CachedImageProps = ImageProps & {
|
||||
src: string;
|
||||
type: 'tmdb' | 'avatar';
|
||||
};
|
||||
|
||||
/**
|
||||
* The CachedImage component should be used wherever
|
||||
* we want to offer the option to locally cache images.
|
||||
**/
|
||||
const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
|
||||
const CachedImage = ({ src, ...props }: ImageProps) => {
|
||||
const { currentSettings } = useSettings();
|
||||
|
||||
let imageUrl: string;
|
||||
let imageUrl = src;
|
||||
|
||||
if (type === 'tmdb') {
|
||||
// tmdb stuff
|
||||
imageUrl =
|
||||
currentSettings.cacheImages && !src.startsWith('/')
|
||||
? src.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;
|
||||
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
|
||||
const parsedUrl = new URL(imageUrl);
|
||||
|
||||
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
|
||||
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
|
||||
}
|
||||
}
|
||||
|
||||
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
|
||||
|
||||
@@ -61,7 +61,6 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
|
||||
{...props}
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
alt=""
|
||||
src={imageUrl}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import PersonCard from '@app/components/PersonCard';
|
||||
import TitleCard from '@app/components/TitleCard';
|
||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import useVerticalScroll from '@app/hooks/useVerticalScroll';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type {
|
||||
CollectionResult,
|
||||
@@ -34,14 +32,7 @@ const ListView = ({
|
||||
mutateParent,
|
||||
}: ListViewProps) => {
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
|
||||
|
||||
const blacklistVisibility = hasPermission(
|
||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
||||
{ type: 'or' }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEmpty && (
|
||||
@@ -64,89 +55,76 @@ const ListView = ({
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{items
|
||||
?.filter((title) => {
|
||||
if (!blacklistVisibility)
|
||||
return (
|
||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
||||
MediaStatus.BLACKLISTED
|
||||
{items?.map((title, index) => {
|
||||
let titleCard: React.ReactNode;
|
||||
|
||||
switch (title.mediaType) {
|
||||
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
|
||||
/>
|
||||
);
|
||||
return title;
|
||||
})
|
||||
.map((title, index) => {
|
||||
let titleCard: React.ReactNode;
|
||||
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;
|
||||
}
|
||||
|
||||
switch (title.mediaType) {
|
||||
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>;
|
||||
})}
|
||||
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
|
||||
})}
|
||||
{isLoading &&
|
||||
!isReachingEnd &&
|
||||
[...Array(20)].map((_item, i) => (
|
||||
|
||||
@@ -123,7 +123,6 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
{backdrop && (
|
||||
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={backdrop}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import { CheckCircleIcon } from '@heroicons/react/20/solid';
|
||||
import {
|
||||
BellIcon,
|
||||
ClockIcon,
|
||||
EyeSlashIcon,
|
||||
MinusSmallIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
|
||||
interface StatusBadgeMiniProps {
|
||||
@@ -49,10 +44,6 @@ const StatusBadgeMini = ({
|
||||
);
|
||||
indicatorIcon = <BellIcon />;
|
||||
break;
|
||||
case MediaStatus.BLACKLISTED:
|
||||
badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white');
|
||||
indicatorIcon = <EyeSlashIcon />;
|
||||
break;
|
||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||
badgeStyle.push(
|
||||
'bg-green-500 border-green-400 ring-green-400 text-green-100'
|
||||
|
||||
@@ -33,7 +33,6 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={image}
|
||||
alt={name}
|
||||
className="relative z-40 h-full w-full"
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
CompanySelector,
|
||||
GenreSelector,
|
||||
KeywordSelector,
|
||||
StatusSelector,
|
||||
WatchProviderSelector,
|
||||
} from '@app/components/Selector';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
@@ -41,7 +40,6 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
|
||||
runtime: 'Runtime',
|
||||
streamingservices: 'Streaming Services',
|
||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||
status: 'Status',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
@@ -152,23 +150,6 @@ const FilterSlideover = ({
|
||||
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">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</span>
|
||||
|
||||
@@ -108,7 +108,6 @@ export const QueryFilterOptions = z.object({
|
||||
voteCountGte: z.string().optional(),
|
||||
watchRegion: z.string().optional(),
|
||||
watchProviders: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
@@ -148,10 +147,6 @@ export const prepareFilterValues = (
|
||||
filterValues.genre = values.genre;
|
||||
}
|
||||
|
||||
if (values.status) {
|
||||
filterValues.status = values.status;
|
||||
}
|
||||
|
||||
if (values.keywords) {
|
||||
filterValues.keywords = values.keywords;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import getConfig from 'next/config';
|
||||
|
||||
interface ExternalLinkBlockProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
@@ -30,6 +31,7 @@ const ExternalLinkBlock = ({
|
||||
mediaUrl,
|
||||
}: ExternalLinkBlockProps) => {
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const { locale } = useLocale();
|
||||
|
||||
return (
|
||||
@@ -43,8 +45,7 @@ const ExternalLinkBlock = ({
|
||||
>
|
||||
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
||||
<PlexLogo />
|
||||
) : settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY ? (
|
||||
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
|
||||
<EmbyLogo />
|
||||
) : (
|
||||
<JellyfinLogo />
|
||||
|
||||
@@ -36,7 +36,6 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={image}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
@@ -7,6 +6,7 @@ import { Menu, Transition } from '@headlessui/react';
|
||||
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||
import type { default as IssueCommentType } from '@server/entity/IssueComment';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
@@ -88,8 +88,7 @@ const IssueComment = ({
|
||||
</Modal>
|
||||
</Transition>
|
||||
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={comment.user.avatar}
|
||||
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"
|
||||
|
||||
@@ -28,6 +28,8 @@ import type Issue from '@server/entity/Issue';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import getConfig from 'next/config';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -106,6 +108,7 @@ const IssueDetails = () => {
|
||||
(opt) => opt.issueType === issueData?.issueType
|
||||
);
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
@@ -217,7 +220,6 @@ const IssueDetails = () => {
|
||||
{data.backdropPath && (
|
||||
<div className="media-page-bg-image">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -236,7 +238,6 @@ const IssueDetails = () => {
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
@@ -288,11 +289,10 @@ const IssueDetails = () => {
|
||||
}
|
||||
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
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}
|
||||
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}
|
||||
height={20}
|
||||
/>
|
||||
@@ -390,8 +390,7 @@ const IssueDetails = () => {
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
@@ -438,8 +437,7 @@ const IssueDetails = () => {
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.play4konplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
@@ -664,8 +662,7 @@ const IssueDetails = () => {
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
@@ -711,8 +708,7 @@ const IssueDetails = () => {
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.play4konplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MediaType } from '@server/constants/media';
|
||||
import type Issue from '@server/entity/Issue';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
@@ -112,7 +113,6 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
{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' }}
|
||||
@@ -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"
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
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}`}
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={issue.createdBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm ml-1.5 object-cover"
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
CogIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
ExclamationTriangleIcon,
|
||||
EyeSlashIcon,
|
||||
FilmIcon,
|
||||
SparklesIcon,
|
||||
TvIcon,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
ClockIcon as FilledClockIcon,
|
||||
CogIcon as FilledCogIcon,
|
||||
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
|
||||
EyeSlashIcon as FilledEyeSlashIcon,
|
||||
FilmIcon as FilledFilmIcon,
|
||||
SparklesIcon as FilledSparklesIcon,
|
||||
TvIcon as FilledTvIcon,
|
||||
@@ -86,18 +84,6 @@ const MobileMenu = () => {
|
||||
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
|
||||
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',
|
||||
content: intl.formatMessage(menuMessages.issues),
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
ExclamationTriangleIcon,
|
||||
EyeSlashIcon,
|
||||
FilmIcon,
|
||||
SparklesIcon,
|
||||
TvIcon,
|
||||
@@ -26,7 +25,6 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
|
||||
browsemovies: 'Movies',
|
||||
browsetv: 'Series',
|
||||
requests: 'Requests',
|
||||
blacklist: 'Blacklist',
|
||||
issues: 'Issues',
|
||||
users: 'Users',
|
||||
settings: 'Settings',
|
||||
@@ -73,17 +71,6 @@ const SidebarLinks: SidebarLinkProps[] = [
|
||||
svgIcon: <ClockIcon className="mr-3 h-6 w-6" />,
|
||||
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',
|
||||
messagesKey: 'issues',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||
import Image from 'next/image';
|
||||
import type { LinkProps } from 'next/link';
|
||||
import Link from 'next/link';
|
||||
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"
|
||||
data-testid="user-menu"
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||
src={user ? user.avatar : ''}
|
||||
src={user?.avatar || ''}
|
||||
alt=""
|
||||
width={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="flex flex-col space-y-4 px-4 py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||
src={user ? user.avatar : ''}
|
||||
src={user?.avatar || ''}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
|
||||
@@ -4,9 +4,9 @@ import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
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 * as Yup from 'yup';
|
||||
|
||||
@@ -26,7 +26,6 @@ const messages = defineMessages('components.Login', {
|
||||
validationemailformat: 'Valid email required',
|
||||
validationusernamerequired: 'Username required',
|
||||
validationpasswordrequired: 'Password required',
|
||||
validationservertyperequired: 'Please select a server type',
|
||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||
validationPortRequired: 'You must provide a valid port number',
|
||||
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
@@ -41,51 +40,42 @@ const messages = defineMessages('components.Login', {
|
||||
initialsigningin: 'Connecting…',
|
||||
initialsignin: 'Connect',
|
||||
forgotpassword: 'Forgot Password?',
|
||||
servertype: 'Server Type',
|
||||
back: 'Go back',
|
||||
});
|
||||
|
||||
interface JellyfinLoginProps {
|
||||
revalidate: () => void;
|
||||
initial?: boolean;
|
||||
serverType?: MediaServerType;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
revalidate,
|
||||
initial,
|
||||
serverType,
|
||||
onCancel,
|
||||
}) => {
|
||||
const toasts = useToasts();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
serverType === MediaServerType.JELLYFIN
|
||||
? ServerType.JELLYFIN
|
||||
: serverType === MediaServerType.EMBY
|
||||
? ServerType.EMBY
|
||||
: 'Media Server',
|
||||
};
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
if (initial) {
|
||||
const LoginSchema = Yup.object().shape({
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(
|
||||
messages.validationhostrequired,
|
||||
mediaServerFormatValues
|
||||
)
|
||||
intl.formatMessage(messages.validationhostrequired, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||
})
|
||||
),
|
||||
port: Yup.number().required(
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
urlBase: Yup.string().matches(
|
||||
/^(.*[^/])$/,
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
||||
),
|
||||
urlBase: Yup.string()
|
||||
.matches(
|
||||
/^(\/[^/].*[^/]$)/,
|
||||
intl.formatMessage(messages.validationUrlBaseLeadingSlash)
|
||||
)
|
||||
.matches(
|
||||
/^(.*[^/])$/,
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
||||
),
|
||||
email: Yup.string()
|
||||
.email(intl.formatMessage(messages.validationemailformat))
|
||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||
@@ -95,6 +85,11 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
password: Yup.string(),
|
||||
});
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
@@ -109,11 +104,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
validationSchema={LoginSchema}
|
||||
onSubmit={async (values) => {
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -127,7 +117,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
useSsl: values.useSsl,
|
||||
urlBase: values.urlBase,
|
||||
email: values.email,
|
||||
serverType: serverType,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
@@ -323,7 +312,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
@@ -335,13 +324,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</Button>
|
||||
</span>
|
||||
{onCancel && (
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button buttonType="default" onClick={() => onCancel()}>
|
||||
<FormattedMessage {...messages.back} />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
@@ -447,8 +429,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
jellyfinForgotPasswordUrl
|
||||
? `${jellyfinForgotPasswordUrl}`
|
||||
: `${baseUrl}/web/index.html#!/${
|
||||
settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
process.env.JELLYFIN_TYPE === 'emby'
|
||||
? 'startup/'
|
||||
: ''
|
||||
}forgotpassword.html`
|
||||
|
||||
@@ -10,6 +10,7 @@ import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { XCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import getConfig from 'next/config';
|
||||
import { useRouter } from 'next/dist/client/router';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -33,6 +34,7 @@ const Login = () => {
|
||||
const { user, revalidate } = useUser();
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
// 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
|
||||
@@ -86,15 +88,6 @@ const Login = () => {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
|
||||
<PageTitle title={intl.formatMessage(messages.signin)} />
|
||||
@@ -161,10 +154,12 @@ const Login = () => {
|
||||
{settings.currentSettings.mediaServerType ==
|
||||
MediaServerType.PLEX
|
||||
? intl.formatMessage(messages.signinwithplex)
|
||||
: intl.formatMessage(
|
||||
messages.signinwithjellyfin,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
: intl.formatMessage(messages.signinwithjellyfin, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
})}
|
||||
</button>
|
||||
<AccordionContent isOpen={openIndexes.includes(0)}>
|
||||
<div className="px-10 py-8">
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import BlacklistBlock from '@app/components/BlacklistBlock';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||
import SlideOver from '@app/components/Common/SlideOver';
|
||||
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 { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import getConfig from 'next/config';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
@@ -95,6 +95,7 @@ const ManageSlideOver = ({
|
||||
const { user: currentUser, hasPermission } = useUser();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const { data: watchData } = useSWR<MediaWatchDataResponse>(
|
||||
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
|
||||
data.mediaInfo &&
|
||||
@@ -285,20 +286,6 @@ const ManageSlideOver = ({
|
||||
</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) &&
|
||||
(data.mediaInfo?.serviceUrl ||
|
||||
data.mediaInfo?.tautulliUrl ||
|
||||
@@ -368,8 +355,7 @@ const ManageSlideOver = ({
|
||||
key={`watch-user-${user.id}`}
|
||||
content={user.displayName}
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={user.avatar}
|
||||
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"
|
||||
@@ -530,8 +516,7 @@ const ManageSlideOver = ({
|
||||
key={`watch-user-${user.id}`}
|
||||
content={user.displayName}
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={user.avatar}
|
||||
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"
|
||||
@@ -620,17 +605,32 @@ const ManageSlideOver = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasPermission(Permission.ADMIN) &&
|
||||
data?.mediaInfo &&
|
||||
data.mediaInfo.status !== MediaStatus.BLACKLISTED && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalAdvanced)}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||
{hasPermission(Permission.ADMIN) && data?.mediaInfo && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalAdvanced)}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||
<Button
|
||||
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
|
||||
onClick={() => markAvailable()}
|
||||
onClick={() => markAvailable(true)}
|
||||
className="w-full"
|
||||
buttonType="success"
|
||||
>
|
||||
@@ -638,59 +638,41 @@ const ManageSlideOver = ({
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? messages.markavailable
|
||||
: messages.markallseasonsavailable
|
||||
? messages.mark4kavailable
|
||||
: messages.markallseasons4kavailable
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled && (
|
||||
<Button
|
||||
onClick={() => markAvailable(true)}
|
||||
className="w-full"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? messages.mark4kavailable
|
||||
: messages.markallseasons4kavailable
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMedia()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<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>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMedia()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<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:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: 'Jellyfin',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,8 @@ import PersonCard from '@app/components/PersonCard';
|
||||
import Slider from '@app/components/Slider';
|
||||
import TitleCard from '@app/components/TitleCard';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import type {
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
@@ -43,7 +41,6 @@ const MediaSlider = ({
|
||||
onNewTitles,
|
||||
}: MediaSliderProps) => {
|
||||
const settings = useSettings();
|
||||
const { hasPermission } = useUser();
|
||||
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
|
||||
(pageIndex: number, previousPageData: MixedResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
@@ -93,65 +90,50 @@ const MediaSlider = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const blacklistVisibility = hasPermission(
|
||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
||||
{ type: 'or' }
|
||||
);
|
||||
|
||||
const finalTitles = titles
|
||||
.slice(0, 20)
|
||||
.filter((title) => {
|
||||
if (!blacklistVisibility)
|
||||
const finalTitles = titles.slice(0, 20).map((title) => {
|
||||
switch (title.mediaType) {
|
||||
case 'movie':
|
||||
return (
|
||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
||||
MediaStatus.BLACKLISTED
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
return title;
|
||||
})
|
||||
.map((title) => {
|
||||
switch (title.mediaType) {
|
||||
case 'movie':
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
case 'tv':
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
case 'tv':
|
||||
return (
|
||||
<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) {
|
||||
finalTitles.push(
|
||||
|
||||
@@ -3,9 +3,7 @@ import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
|
||||
import RTFresh from '@app/assets/rt_fresh.svg';
|
||||
import RTRotten from '@app/assets/rt_rotten.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 BlacklistModal from '@app/components/BlacklistModal';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
@@ -27,7 +25,7 @@ import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
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 defineMessages from '@app/utils/defineMessages';
|
||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||
@@ -36,7 +34,6 @@ import {
|
||||
CloudIcon,
|
||||
CogIcon,
|
||||
ExclamationTriangleIcon,
|
||||
EyeSlashIcon,
|
||||
FilmIcon,
|
||||
PlayIcon,
|
||||
TicketIcon,
|
||||
@@ -44,22 +41,20 @@ import {
|
||||
import {
|
||||
ChevronDoubleDownIcon,
|
||||
ChevronDoubleUpIcon,
|
||||
MinusCircleIcon,
|
||||
StarIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { type RatingResponse } from '@server/api/ratings';
|
||||
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 type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
|
||||
import { countries } from 'country-flag-icons';
|
||||
import 'country-flag-icons/3x2/flags.css';
|
||||
import { uniqBy } from 'lodash';
|
||||
import getConfig from 'next/config';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages('components.MovieDetails', {
|
||||
@@ -99,12 +94,6 @@ const messages = defineMessages('components.MovieDetails', {
|
||||
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
||||
tmdbuserscore: 'TMDB 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 {
|
||||
@@ -123,14 +112,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
const minStudios = 3;
|
||||
const [showMoreStudios, setShowMoreStudios] = useState(false);
|
||||
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
||||
!movie?.onUserWatchlist
|
||||
);
|
||||
const [isBlacklistUpdating, setIsBlacklistUpdating] =
|
||||
useState<boolean>(false);
|
||||
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
|
||||
const { addToast } = useToasts();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -160,11 +142,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
setShowManager(router.query.manage == '1' ? true : false);
|
||||
}, [router.query.manage]);
|
||||
|
||||
const closeBlacklistModal = useCallback(
|
||||
() => setShowBlacklistModal(false),
|
||||
[]
|
||||
);
|
||||
|
||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||
mediaUrl: data?.mediaInfo?.mediaUrl,
|
||||
mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
|
||||
@@ -177,7 +154,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <ErrorPage statusCode={404} />;
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
|
||||
@@ -287,7 +264,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
?.flatrate ?? [];
|
||||
|
||||
function getAvalaibleMediaServerName() {
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||
}
|
||||
|
||||
@@ -299,8 +276,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
}
|
||||
|
||||
function getAvalaible4kMediaServerName() {
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
|
||||
return intl.formatMessage(messages.play4k, { mediaServerName: 'Emby' });
|
||||
}
|
||||
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
|
||||
@@ -310,134 +287,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
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 (
|
||||
<div
|
||||
className="media-page"
|
||||
@@ -448,7 +297,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
{data.backdropPath && (
|
||||
<div className="media-page-bg-image">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -484,18 +332,9 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
revalidate={() => revalidate()}
|
||||
show={showManager}
|
||||
/>
|
||||
<BlacklistModal
|
||||
tmdbId={data.id}
|
||||
type="movie"
|
||||
show={showBlacklistModal}
|
||||
onCancel={closeBlacklistModal}
|
||||
onComplete={onClickHideItemBtn}
|
||||
isUpdating={isBlacklistUpdating}
|
||||
/>
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
@@ -569,61 +408,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
</span>
|
||||
</div>
|
||||
<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} />
|
||||
<RequestButton
|
||||
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="absolute inset-0 z-0">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
|
||||
alt=""
|
||||
style={{
|
||||
|
||||
@@ -78,13 +78,6 @@ export const messages = defineMessages('components.PermissionEdit', {
|
||||
viewwatchlists: 'View {mediaServerName} Watchlists',
|
||||
viewwatchlistsDescription:
|
||||
"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 {
|
||||
@@ -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 (
|
||||
|
||||
@@ -51,7 +51,6 @@ const PersonCard = ({
|
||||
{profilePath ? (
|
||||
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
||||
alt=""
|
||||
style={{
|
||||
|
||||
@@ -227,7 +227,6 @@ const PersonDetails = () => {
|
||||
{data.profilePath && (
|
||||
<div className="relative mb-6 mr-0 h-36 w-36 flex-shrink-0 overflow-hidden rounded-full ring-1 ring-gray-700 lg:mb-0 lg:mr-6 lg:h-44 lg:w-44">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
|
||||
@@ -300,7 +300,6 @@ const RequestButton = ({
|
||||
}) &&
|
||||
media &&
|
||||
media.status !== MediaStatus.AVAILABLE &&
|
||||
media.status !== MediaStatus.BLACKLISTED &&
|
||||
!isShowComplete
|
||||
) {
|
||||
buttons.push({
|
||||
@@ -346,7 +345,6 @@ const RequestButton = ({
|
||||
}) &&
|
||||
media &&
|
||||
media.status4k !== MediaStatus.AVAILABLE &&
|
||||
media.status !== MediaStatus.BLACKLISTED &&
|
||||
!is4kShowComplete &&
|
||||
settings.currentSettings.series4kEnabled
|
||||
) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
@@ -115,8 +116,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||
className="group flex items-center"
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -346,7 +346,6 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -391,8 +390,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
className="group flex items-center"
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -605,7 +603,6 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
className="w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28"
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
@@ -42,7 +43,6 @@ const messages = defineMessages('components.RequestList.RequestItem', {
|
||||
tmdbid: 'TMDB ID',
|
||||
tvdbid: 'TheTVDB ID',
|
||||
unknowntitle: 'Unknown Title',
|
||||
removearr: 'Remove from {arr}',
|
||||
profileName: 'Profile',
|
||||
});
|
||||
|
||||
@@ -190,8 +190,7 @@ const RequestItemError = ({
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -250,8 +249,7 @@ const RequestItemError = ({
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={requestData.modifiedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -344,18 +342,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
revalidateList();
|
||||
};
|
||||
|
||||
const deleteMediaFile = async () => {
|
||||
if (request.media) {
|
||||
await fetch(`/api/v1/media/${request.media.id}/file`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
await fetch(`/api/v1/media/${request.media.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
revalidateList();
|
||||
}
|
||||
};
|
||||
|
||||
const retryRequest = async () => {
|
||||
setRetrying(true);
|
||||
|
||||
@@ -420,7 +406,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
{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' }}
|
||||
@@ -446,7 +431,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
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}`
|
||||
@@ -573,8 +557,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -633,8 +616,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -685,28 +667,14 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
)}
|
||||
{requestData.status !== MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteRequest()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
||||
</ConfirmButton>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMediaFile()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.removearr, {
|
||||
arr: request.type === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</ConfirmButton>
|
||||
</>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteRequest()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
||||
</ConfirmButton>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||
import type { User } from '@app/hooks/useUser';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
@@ -15,6 +14,7 @@ import type {
|
||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import { hasPermission } from '@server/lib/permissions';
|
||||
import { isEqual } from 'lodash';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import Select from 'react-select';
|
||||
@@ -561,8 +561,7 @@ const AdvancedRequester = ({
|
||||
<span className="inline-block w-full rounded-md shadow-sm">
|
||||
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
|
||||
<span className="flex items-center">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={selectedUser.avatar}
|
||||
alt=""
|
||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||
@@ -614,8 +613,7 @@ const AdvancedRequester = ({
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} flex items-center`}
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||
|
||||
@@ -66,9 +66,7 @@ const CollectionRequestModal = ({
|
||||
(quota?.movie.remaining ?? 0) - selectedParts.length;
|
||||
|
||||
const getAllParts = (): number[] => {
|
||||
return (data?.parts ?? [])
|
||||
.filter((part) => part.mediaInfo?.status !== MediaStatus.BLACKLISTED)
|
||||
.map((part) => part.id);
|
||||
return (data?.parts ?? []).map((part) => part.id);
|
||||
};
|
||||
|
||||
const getAllRequestedParts = (): number[] => {
|
||||
@@ -250,11 +248,6 @@ const CollectionRequestModal = ({
|
||||
{ type: 'or' }
|
||||
);
|
||||
|
||||
const blacklistVisibility = hasPermission(
|
||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
||||
{ type: 'or' }
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
loading={(!data && !error) || !quota}
|
||||
@@ -351,157 +344,122 @@ const CollectionRequestModal = ({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{data?.parts
|
||||
.filter((part) => {
|
||||
if (!blacklistVisibility)
|
||||
return (
|
||||
part.mediaInfo?.status !== MediaStatus.BLACKLISTED
|
||||
);
|
||||
return part;
|
||||
})
|
||||
.map((part) => {
|
||||
const partRequest = getPartRequest(part.id);
|
||||
const partMedia =
|
||||
part.mediaInfo &&
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN
|
||||
? part.mediaInfo
|
||||
: undefined;
|
||||
{data?.parts.map((part) => {
|
||||
const partRequest = getPartRequest(part.id);
|
||||
const partMedia =
|
||||
part.mediaInfo &&
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN
|
||||
? part.mediaInfo
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<tr key={`part-${part.id}`}>
|
||||
<td
|
||||
className={`whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100 ${
|
||||
partMedia?.status === MediaStatus.BLACKLISTED &&
|
||||
'pointer-events-none opacity-50'
|
||||
return (
|
||||
<tr key={`part-${part.id}`}>
|
||||
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={
|
||||
!!partMedia || isSelectedPart(part.id)
|
||||
}
|
||||
onClick={() => togglePart(part.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
togglePart(part.id);
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
|
||||
!!partMedia ||
|
||||
partRequest ||
|
||||
(quota?.movie.limit &&
|
||||
currentlyRemaining <= 0 &&
|
||||
!isSelectedPart(part.id))
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={
|
||||
(!!partMedia &&
|
||||
partMedia.status !==
|
||||
MediaStatus.BLACKLISTED) ||
|
||||
isSelectedPart(part.id)
|
||||
}
|
||||
onClick={() => togglePart(part.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
togglePart(part.id);
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
|
||||
(!!partMedia &&
|
||||
partMedia.status !==
|
||||
MediaStatus.BLACKLISTED) ||
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
!!partMedia ||
|
||||
partRequest ||
|
||||
(quota?.movie.limit &&
|
||||
currentlyRemaining <= 0 &&
|
||||
!isSelectedPart(part.id))
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
(!!partMedia &&
|
||||
partMedia.status !==
|
||||
MediaStatus.BLACKLISTED) ||
|
||||
partRequest ||
|
||||
isSelectedPart(part.id)
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-gray-700'
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
(!!partMedia &&
|
||||
partMedia.status !==
|
||||
MediaStatus.BLACKLISTED) ||
|
||||
partRequest ||
|
||||
isSelectedPart(part.id)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className={`flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 ${
|
||||
partMedia?.status === MediaStatus.BLACKLISTED &&
|
||||
'pointer-events-none opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
part.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
sizes="100vw"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
width={600}
|
||||
height={900}
|
||||
/>
|
||||
isSelectedPart(part.id)
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-gray-700'
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
!!partMedia ||
|
||||
partRequest ||
|
||||
isSelectedPart(part.id)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
||||
<div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md">
|
||||
<CachedImage
|
||||
src={
|
||||
part.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
sizes="100vw"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
width={600}
|
||||
height={900}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center pl-2">
|
||||
<div className="text-xs font-medium">
|
||||
{part.releaseDate?.slice(0, 4)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center pl-2">
|
||||
<div className="text-xs font-medium">
|
||||
{part.releaseDate?.slice(0, 4)}
|
||||
</div>
|
||||
<div className="text-base font-bold">
|
||||
{part.title}
|
||||
</div>
|
||||
<div className="text-base font-bold">
|
||||
{part.title}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6">
|
||||
{!partMedia && !partRequest && (
|
||||
<Badge>
|
||||
{intl.formatMessage(
|
||||
globalMessages.notrequested
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6">
|
||||
{!partMedia && !partRequest && (
|
||||
<Badge>
|
||||
{intl.formatMessage(globalMessages.notrequested)}
|
||||
</Badge>
|
||||
)}
|
||||
{!partMedia &&
|
||||
partRequest?.status ===
|
||||
MediaRequestStatus.PENDING && (
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</Badge>
|
||||
)}
|
||||
{!partMedia &&
|
||||
partRequest?.status ===
|
||||
MediaRequestStatus.PENDING && (
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</Badge>
|
||||
)}
|
||||
{((!partMedia &&
|
||||
partRequest?.status ===
|
||||
MediaRequestStatus.APPROVED) ||
|
||||
partMedia?.[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PROCESSING) && (
|
||||
<Badge badgeType="primary">
|
||||
{intl.formatMessage(globalMessages.requested)}
|
||||
</Badge>
|
||||
)}
|
||||
{partMedia?.[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</Badge>
|
||||
)}
|
||||
{partMedia?.status === MediaStatus.BLACKLISTED && (
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(globalMessages.blacklisted)}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{((!partMedia &&
|
||||
partRequest?.status ===
|
||||
MediaRequestStatus.APPROVED) ||
|
||||
partMedia?.[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PROCESSING) && (
|
||||
<Badge badgeType="primary">
|
||||
{intl.formatMessage(globalMessages.requested)}
|
||||
</Badge>
|
||||
)}
|
||||
{partMedia?.[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -33,13 +33,6 @@ const messages = defineMessages('components.Selector', {
|
||||
nooptions: 'No results.',
|
||||
showmore: 'Show More',
|
||||
showless: 'Show Less',
|
||||
searchStatus: 'Select status...',
|
||||
returningSeries: 'Returning Series',
|
||||
planned: 'Planned',
|
||||
inProduction: 'In Production',
|
||||
ended: 'Ended',
|
||||
canceled: 'Canceled',
|
||||
pilot: 'Pilot',
|
||||
});
|
||||
|
||||
type SingleVal = {
|
||||
@@ -211,75 +204,6 @@ export const GenreSelector = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusSelector = ({
|
||||
isMulti,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
||||
const intl = useIntl();
|
||||
const [defaultDataValue, setDefaultDataValue] = useState<
|
||||
{ label: string; value: number }[] | null
|
||||
>(null);
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{ name: intl.formatMessage(messages.returningSeries), id: 0 },
|
||||
{ name: intl.formatMessage(messages.planned), id: 1 },
|
||||
{ name: intl.formatMessage(messages.inProduction), id: 2 },
|
||||
{ name: intl.formatMessage(messages.ended), id: 3 },
|
||||
{ name: intl.formatMessage(messages.canceled), id: 4 },
|
||||
{ name: intl.formatMessage(messages.pilot), id: 5 },
|
||||
],
|
||||
[intl]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadDefaultStatus = async (): Promise<void> => {
|
||||
if (!defaultValue) {
|
||||
return;
|
||||
}
|
||||
const statuses = defaultValue.split('|');
|
||||
|
||||
const statusData = options
|
||||
.filter((opt) => statuses.find((s) => Number(s) === opt.id))
|
||||
.map((o) => ({
|
||||
label: o.name,
|
||||
value: o.id,
|
||||
}));
|
||||
|
||||
setDefaultDataValue(statusData);
|
||||
};
|
||||
|
||||
loadDefaultStatus();
|
||||
}, [defaultValue, options]);
|
||||
|
||||
const loadStatusOptions = async () => {
|
||||
return options
|
||||
.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}))
|
||||
.filter(({ label }) => label.toLowerCase());
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
key={`status-select-${defaultDataValue}`}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
|
||||
defaultOptions
|
||||
isMulti={isMulti}
|
||||
loadOptions={loadStatusOptions}
|
||||
placeholder={intl.formatMessage(messages.searchStatus)}
|
||||
onChange={(value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onChange(value as any);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const KeywordSelector = ({
|
||||
isMulti,
|
||||
defaultValue,
|
||||
@@ -452,7 +376,6 @@ export const WatchProviderSelector = ({
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||
alt=""
|
||||
style={{
|
||||
@@ -498,7 +421,6 @@ export const WatchProviderSelector = ({
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||
alt=""
|
||||
style={{
|
||||
|
||||
@@ -3,14 +3,13 @@ import Button from '@app/components/Common/Button';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||
import LibraryItem from '@app/components/Settings/LibraryItem';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { JellyfinSettings } from '@server/lib/settings';
|
||||
import { Field, Formik } from 'formik';
|
||||
import getConfig from 'next/config';
|
||||
import { useState } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
@@ -62,9 +61,6 @@ const messages = defineMessages('components.Settings', {
|
||||
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||
tip: 'Tip',
|
||||
scanbackground:
|
||||
'Scanning will run in the background. You can continue the setup process in the meantime.',
|
||||
});
|
||||
|
||||
interface Library {
|
||||
@@ -82,13 +78,13 @@ interface SyncStatus {
|
||||
}
|
||||
|
||||
interface SettingsJellyfinProps {
|
||||
isSetupSettings?: boolean;
|
||||
showAdvancedSettings?: boolean;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
onComplete,
|
||||
isSetupSettings,
|
||||
showAdvancedSettings,
|
||||
}) => {
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const toasts = useToasts();
|
||||
@@ -106,7 +102,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
);
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const JellyfinSettingsSchema = Yup.object().shape({
|
||||
hostname: Yup.string()
|
||||
@@ -288,29 +284,26 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(
|
||||
messages.jellyfinlibraries,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.jellyfinlibraries, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: intl.formatMessage(messages.jellyfinlibraries, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(
|
||||
messages.jellyfinlibrariesDescription,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.jellyfinlibrariesDescription, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: intl.formatMessage(messages.jellyfinlibrariesDescription, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section">
|
||||
@@ -347,10 +340,13 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
<FormattedMessage {...messages.manualscanJellyfin} />
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(
|
||||
messages.manualscanDescriptionJellyfin,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.manualscanDescriptionJellyfin, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: intl.formatMessage(messages.manualscanDescriptionJellyfin, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section">
|
||||
@@ -450,26 +446,24 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isSetupSettings && (
|
||||
<div className="text-sm text-gray-500">
|
||||
<span className="mr-2">
|
||||
<Badge>{intl.formatMessage(messages.tip)}</Badge>
|
||||
</span>
|
||||
{intl.formatMessage(messages.scanbackground)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-10 mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(
|
||||
messages.jellyfinSettings,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.jellyfinSettings, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: intl.formatMessage(messages.jellyfinSettings, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(
|
||||
messages.jellyfinSettingsDescription,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.jellyfinSettingsDescription, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: intl.formatMessage(messages.jellyfinSettingsDescription, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Formik
|
||||
@@ -503,10 +497,12 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
|
||||
addToast(
|
||||
intl.formatMessage(
|
||||
messages.jellyfinSettingsSuccess,
|
||||
mediaServerFormatValues
|
||||
),
|
||||
intl.formatMessage(messages.jellyfinSettingsSuccess, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
}),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
@@ -522,10 +518,12 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
}
|
||||
if (errorData?.message === ApiErrorCode.InvalidUrl) {
|
||||
addToast(
|
||||
intl.formatMessage(
|
||||
messages.invalidurlerror,
|
||||
mediaServerFormatValues
|
||||
),
|
||||
intl.formatMessage(messages.invalidurlerror, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
}),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
@@ -533,10 +531,12 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
);
|
||||
} else {
|
||||
addToast(
|
||||
intl.formatMessage(
|
||||
messages.jellyfinSettingsFailure,
|
||||
mediaServerFormatValues
|
||||
),
|
||||
intl.formatMessage(messages.jellyfinSettingsFailure, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
}),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
@@ -559,7 +559,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<form className="section" onSubmit={handleSubmit}>
|
||||
{!isSetupSettings && (
|
||||
{showAdvancedSettings && (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<label htmlFor="hostname" className="text-label">
|
||||
@@ -643,7 +643,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isSetupSettings && (
|
||||
{showAdvancedSettings && (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<label htmlFor="urlBase" className="text-label">
|
||||
@@ -710,9 +710,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`actions ${isSetupSettings ? 'mt-0 border-0' : ''}`}
|
||||
>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
|
||||
@@ -7,7 +7,6 @@ import PageTitle from '@app/components/Common/PageTitle';
|
||||
import Table from '@app/components/Common/Table';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { formatBytes } from '@app/utils/numberHelpers';
|
||||
@@ -58,8 +57,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
|
||||
'plex-recently-added-scan': 'Plex Recently Added Scan',
|
||||
'plex-full-scan': 'Plex Full Library Scan',
|
||||
'plex-watchlist-sync': 'Plex Watchlist Sync',
|
||||
'jellyfin-full-scan': 'Jellyfin Full Library Scan',
|
||||
'jellyfin-recently-added-scan': 'Jellyfin Recently Added Scan',
|
||||
'jellyfin-full-scan': 'Jellyfin Full Library Scan',
|
||||
'availability-sync': 'Media Availability Sync',
|
||||
'radarr-scan': 'Radarr Scan',
|
||||
'sonarr-scan': 'Sonarr Scan',
|
||||
@@ -82,7 +81,6 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
|
||||
'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
||||
imagecachecount: 'Images Cached',
|
||||
imagecachesize: 'Total Cache Size',
|
||||
usersavatars: "Users' Avatars",
|
||||
}
|
||||
);
|
||||
|
||||
@@ -169,20 +167,6 @@ const SettingsJobs = () => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const settings = useSettings();
|
||||
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
messages['jellyfin-recently-added-scan'] = {
|
||||
id: 'jellyfin-recently-added-scan',
|
||||
defaultMessage: 'Emby Recently Added Scan',
|
||||
};
|
||||
}
|
||||
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
messages['jellyfin-full-scan'] = {
|
||||
id: 'jellyfin-full-scan',
|
||||
defaultMessage: 'Emby Full Library Scan',
|
||||
};
|
||||
}
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -574,19 +558,6 @@ const SettingsJobs = () => {
|
||||
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
|
||||
</Table.TD>
|
||||
</tr>
|
||||
<tr>
|
||||
<Table.TD>
|
||||
{intl.formatMessage(messages.usersavatars)} (avatar)
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{intl.formatNumber(
|
||||
cacheData?.imageCache.avatar.imageCount ?? 0
|
||||
)}
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{formatBytes(cacheData?.imageCache.avatar.size ?? 0)}
|
||||
</Table.TD>
|
||||
</tr>
|
||||
</Table.TBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import useSettings from '@app/hooks/useSettings';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import getConfig from 'next/config';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Settings', {
|
||||
@@ -25,6 +26,7 @@ type SettingsLayoutProps = {
|
||||
|
||||
const SettingsLayout = ({ children }: SettingsLayoutProps) => {
|
||||
const intl = useIntl();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const settings = useSettings();
|
||||
const settingsRoutes: SettingsRoute[] = [
|
||||
{
|
||||
@@ -87,11 +89,7 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
|
||||
function getAvailableMediaServerName() {
|
||||
return intl.formatMessage(messages.menuJellyfinSettings, {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: undefined,
|
||||
publicRuntimeConfig.JELLYFIN_TYPE === 'emby' ? 'Emby' : 'Jellyfin',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { MainSettings } from '@server/lib/settings';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import getConfig from 'next/config';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
@@ -41,20 +42,12 @@ const SettingsUsers = () => {
|
||||
mutate: revalidate,
|
||||
} = useSWR<MainSettings>('/api/v1/settings/main');
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -128,10 +121,16 @@ const SettingsUsers = () => {
|
||||
<label htmlFor="localLogin" className="checkbox-label">
|
||||
{intl.formatMessage(messages.localLogin)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(
|
||||
messages.localLoginTip,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
{intl.formatMessage(messages.localLoginTip, {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: 'Emby',
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
@@ -147,15 +146,25 @@ const SettingsUsers = () => {
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="newPlexLogin" className="checkbox-label">
|
||||
{intl.formatMessage(
|
||||
messages.newPlexLogin,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
{intl.formatMessage(messages.newPlexLogin, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: 'Jellyfin',
|
||||
})}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(
|
||||
messages.newPlexLoginTip,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
{intl.formatMessage(messages.newPlexLoginTip, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: 'Jellyfin',
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Accordion from '@app/components/Common/Accordion';
|
||||
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
|
||||
import PlexLoginButton from '@app/components/PlexLoginButton';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import getConfig from 'next/config';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Setup', {
|
||||
welcome: 'Welcome to Jellyseerr',
|
||||
signinMessage: 'Get started by signing in',
|
||||
signin: 'Sign in to your account',
|
||||
signinWithJellyfin: 'Enter your Jellyfin details',
|
||||
signinWithEmby: 'Enter your Emby details',
|
||||
signinWithPlex: 'Enter your Plex details',
|
||||
back: 'Go back',
|
||||
signinWithJellyfin: 'Use your {mediaServerName} account',
|
||||
signinWithPlex: 'Use your Plex account',
|
||||
});
|
||||
|
||||
interface LoginWithMediaServerProps {
|
||||
serverType: MediaServerType;
|
||||
onCancel: () => void;
|
||||
onComplete: () => void;
|
||||
onComplete: (onComplete: MediaServerType) => void;
|
||||
}
|
||||
|
||||
const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
||||
serverType,
|
||||
onCancel,
|
||||
onComplete,
|
||||
}) => {
|
||||
const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
|
||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||
const [mediaServerType, setMediaServerType] = useState<MediaServerType>(
|
||||
MediaServerType.NOT_CONFIGURED
|
||||
);
|
||||
const { user, revalidate } = useUser();
|
||||
|
||||
const intl = useIntl();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
||||
// We take the token and attempt to login. If we get a success message, we will
|
||||
// ask swr to revalidate the user which _shouid_ come back with a valid user.
|
||||
@@ -63,60 +56,71 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
onComplete();
|
||||
onComplete(mediaServerType);
|
||||
}
|
||||
}, [user, mediaServerType, onComplete]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div>
|
||||
<div className="mb-2 flex justify-center text-xl font-bold">
|
||||
<FormattedMessage {...messages.signin} />
|
||||
<FormattedMessage {...messages.welcome} />
|
||||
</div>
|
||||
<div className="mb-2 flex justify-center pb-6 text-sm">
|
||||
{serverType === MediaServerType.JELLYFIN ? (
|
||||
<FormattedMessage {...messages.signinWithJellyfin} />
|
||||
) : serverType === MediaServerType.EMBY ? (
|
||||
<FormattedMessage {...messages.signinWithEmby} />
|
||||
) : (
|
||||
<FormattedMessage {...messages.signinWithPlex} />
|
||||
)}
|
||||
<FormattedMessage {...messages.signinMessage} />
|
||||
</div>
|
||||
{serverType === MediaServerType.PLEX && (
|
||||
<>
|
||||
<div
|
||||
className="px-10 py-8"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
<PlexLoginButton
|
||||
onAuthToken={(authToken) => {
|
||||
setMediaServerType(MediaServerType.PLEX);
|
||||
setAuthToken(authToken);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button buttonType="default" onClick={() => onCancel()}>
|
||||
<FormattedMessage {...messages.back} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{serverType === MediaServerType.JELLYFIN && (
|
||||
<JellyfinLogin
|
||||
initial={true}
|
||||
revalidate={revalidate}
|
||||
serverType={serverType}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)}
|
||||
{serverType === MediaServerType.EMBY && (
|
||||
<JellyfinLogin
|
||||
initial={true}
|
||||
revalidate={revalidate}
|
||||
serverType={serverType}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)}
|
||||
<Accordion single atLeastOne>
|
||||
{({ openIndexes, handleClick, AccordionContent }) => (
|
||||
<>
|
||||
<button
|
||||
className={`w-full cursor-default bg-gray-900 py-2 text-center text-sm text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none sm:rounded-t-lg ${
|
||||
openIndexes.includes(0) && 'text-indigo-500'
|
||||
} ${openIndexes.includes(1) && 'border-b border-gray-500'}`}
|
||||
onClick={() => handleClick(0)}
|
||||
>
|
||||
<FormattedMessage {...messages.signinWithPlex} />
|
||||
</button>
|
||||
<AccordionContent isOpen={openIndexes.includes(0)}>
|
||||
<div
|
||||
className="px-10 py-8"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
<PlexLoginButton
|
||||
onAuthToken={(authToken) => {
|
||||
setMediaServerType(MediaServerType.PLEX);
|
||||
setAuthToken(authToken);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
<div>
|
||||
<button
|
||||
className={`w-full cursor-default bg-gray-900 py-2 text-center text-sm text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
|
||||
openIndexes.includes(1)
|
||||
? 'text-indigo-500'
|
||||
: 'sm:rounded-b-lg'
|
||||
}`}
|
||||
onClick={() => handleClick(1)}
|
||||
>
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.signinWithJellyfin, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
: intl.formatMessage(messages.signinWithJellyfin, {
|
||||
mediaServerName: 'Jellyfin',
|
||||
})}
|
||||
</button>
|
||||
<AccordionContent isOpen={openIndexes.includes(1)}>
|
||||
<div
|
||||
className="rounded-b-lg px-10 py-8"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
<JellyfinLogin initial={true} revalidate={revalidate} />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import EmbyLogo from '@app/assets/services/emby.svg';
|
||||
import JellyfinLogo from '@app/assets/services/jellyfin.svg';
|
||||
import PlexLogo from '@app/assets/services/plex.svg';
|
||||
import AppDataWarning from '@app/components/AppDataWarning';
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import ImageFader from '@app/components/Common/ImageFader';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
@@ -11,30 +9,26 @@ import SettingsPlex from '@app/components/Settings/SettingsPlex';
|
||||
import SettingsServices from '@app/components/Settings/SettingsServices';
|
||||
import SetupSteps from '@app/components/Setup/SetupSteps';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import SetupLogin from './SetupLogin';
|
||||
|
||||
const messages = defineMessages('components.Setup', {
|
||||
welcome: 'Welcome to Jellyseerr',
|
||||
subtitle: 'Get started by choosing your media server',
|
||||
configjellyfin: 'Configure Jellyfin',
|
||||
configplex: 'Configure Plex',
|
||||
configemby: 'Configure Emby',
|
||||
setup: 'Setup',
|
||||
finish: 'Finish Setup',
|
||||
finishing: 'Finishing…',
|
||||
continue: 'Continue',
|
||||
servertype: 'Choose Server Type',
|
||||
signin: 'Sign In',
|
||||
configuremediaserver: 'Configure Media Server',
|
||||
configureservices: 'Configure Services',
|
||||
tip: 'Tip',
|
||||
scanbackground:
|
||||
'Scanning will run in the background. You can continue the setup process in the meantime.',
|
||||
});
|
||||
|
||||
const Setup = () => {
|
||||
@@ -48,7 +42,6 @@ const Setup = () => {
|
||||
);
|
||||
const router = useRouter();
|
||||
const { locale } = useLocale();
|
||||
const settings = useSettings();
|
||||
|
||||
const finishSetup = async () => {
|
||||
setIsUpdating(true);
|
||||
@@ -83,25 +76,6 @@ const Setup = () => {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.currentSettings.initialized) {
|
||||
router.push('/');
|
||||
}
|
||||
if (
|
||||
settings.currentSettings.mediaServerType !==
|
||||
MediaServerType.NOT_CONFIGURED
|
||||
) {
|
||||
setCurrentStep(3);
|
||||
setMediaServerType(settings.currentSettings.mediaServerType);
|
||||
}
|
||||
}, [
|
||||
settings.currentSettings.mediaServerType,
|
||||
settings.currentSettings.initialized,
|
||||
router,
|
||||
]);
|
||||
|
||||
if (settings.currentSettings.initialized) return <></>;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col justify-center bg-gray-900 py-12">
|
||||
<PageTitle title={intl.formatMessage(messages.setup)} />
|
||||
@@ -127,120 +101,58 @@ const Setup = () => {
|
||||
>
|
||||
<SetupSteps
|
||||
stepNumber={1}
|
||||
description={intl.formatMessage(messages.servertype)}
|
||||
description={intl.formatMessage(messages.signin)}
|
||||
active={currentStep === 1}
|
||||
completed={currentStep > 1}
|
||||
/>
|
||||
<SetupSteps
|
||||
stepNumber={2}
|
||||
description={intl.formatMessage(messages.signin)}
|
||||
description={intl.formatMessage(messages.configuremediaserver)}
|
||||
active={currentStep === 2}
|
||||
completed={currentStep > 2}
|
||||
/>
|
||||
<SetupSteps
|
||||
stepNumber={3}
|
||||
description={intl.formatMessage(messages.configuremediaserver)}
|
||||
active={currentStep === 3}
|
||||
completed={currentStep > 3}
|
||||
/>
|
||||
<SetupSteps
|
||||
stepNumber={4}
|
||||
description={intl.formatMessage(messages.configureservices)}
|
||||
active={currentStep === 4}
|
||||
active={currentStep === 3}
|
||||
isLastStep
|
||||
/>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="mt-10 w-full rounded-md border border-gray-600 bg-gray-800 bg-opacity-50 p-4 text-white">
|
||||
{currentStep === 1 && (
|
||||
<div className="flex flex-col items-center pb-6">
|
||||
<div className="mb-2 flex justify-center text-xl font-bold">
|
||||
{intl.formatMessage(messages.welcome)}
|
||||
</div>
|
||||
<div className="mb-2 flex justify-center pb-6 text-sm">
|
||||
{intl.formatMessage(messages.subtitle)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="flex flex-col divide-y divide-gray-600 rounded-l border border-gray-600 py-2">
|
||||
<div className="mb-2 flex flex-1 items-center justify-center py-2 px-2">
|
||||
<JellyfinLogo className="h-10" />
|
||||
</div>
|
||||
<div className="px-2 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMediaServerType(MediaServerType.JELLYFIN);
|
||||
setCurrentStep(2);
|
||||
}}
|
||||
className="button-md relative z-10 inline-flex h-full w-full items-center justify-center rounded-md border border-gray-600 bg-transparent px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 hover:border-gray-200 focus:z-20 focus:border-gray-100 focus:outline-none active:border-gray-100"
|
||||
>
|
||||
{intl.formatMessage(messages.configjellyfin)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-gray-600 border-y border-gray-600 py-2">
|
||||
<div className="mb-2 flex flex-1 items-center justify-center py-2 px-2">
|
||||
<PlexLogo className="h-8" />
|
||||
</div>
|
||||
<div className="px-2 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMediaServerType(MediaServerType.PLEX);
|
||||
setCurrentStep(2);
|
||||
}}
|
||||
className="button-md relative z-10 inline-flex h-full w-full items-center justify-center rounded-md border border-gray-600 bg-transparent px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 hover:border-gray-200 focus:z-20 focus:border-gray-100 focus:outline-none active:border-gray-100"
|
||||
>
|
||||
{intl.formatMessage(messages.configplex)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-gray-600 rounded-r border border-gray-600 py-2">
|
||||
<div className="mb-2 flex flex-1 items-center justify-center py-2 px-2">
|
||||
<EmbyLogo className="h-9" />
|
||||
</div>
|
||||
<div className="px-2 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMediaServerType(MediaServerType.EMBY);
|
||||
setCurrentStep(2);
|
||||
}}
|
||||
className="button-md relative z-10 inline-flex h-full w-full items-center justify-center rounded-md border border-gray-600 bg-transparent px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 hover:border-gray-200 focus:z-20 focus:border-gray-100 focus:outline-none active:border-gray-100"
|
||||
>
|
||||
{intl.formatMessage(messages.configemby)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<SetupLogin
|
||||
serverType={mediaServerType}
|
||||
onCancel={() => {
|
||||
setMediaServerType(MediaServerType.NOT_CONFIGURED);
|
||||
setCurrentStep(1);
|
||||
onComplete={(mServerType) => {
|
||||
setMediaServerType(mServerType);
|
||||
setCurrentStep(2);
|
||||
}}
|
||||
onComplete={() => setCurrentStep(3)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<div className="p-2">
|
||||
{currentStep === 2 && (
|
||||
<div>
|
||||
{mediaServerType === MediaServerType.PLEX ? (
|
||||
<SettingsPlex
|
||||
onComplete={() => setMediaServerSettingsComplete(true)}
|
||||
/>
|
||||
) : (
|
||||
<SettingsJellyfin
|
||||
isSetupSettings
|
||||
showAdvancedSettings={false}
|
||||
onComplete={() => setMediaServerSettingsComplete(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
<span className="mr-2">
|
||||
<Badge>{intl.formatMessage(messages.tip)}</Badge>
|
||||
</span>
|
||||
{intl.formatMessage(messages.scanbackground)}
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!mediaServerSettingsComplete}
|
||||
onClick={() => setCurrentStep(4)}
|
||||
onClick={() => setCurrentStep(3)}
|
||||
>
|
||||
{intl.formatMessage(messages.continue)}
|
||||
</Button>
|
||||
@@ -249,7 +161,7 @@ const Setup = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
{currentStep === 3 && (
|
||||
<div>
|
||||
<SettingsServices />
|
||||
<div className="actions">
|
||||
|
||||
@@ -9,6 +9,7 @@ import defineMessages from '@app/utils/defineMessages';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import getConfig from 'next/config';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.StatusBadge', {
|
||||
@@ -17,7 +18,6 @@ const messages = defineMessages('components.StatusBadge', {
|
||||
playonplex: 'Play on {mediaServerName}',
|
||||
openinarr: 'Open in {arr}',
|
||||
managemedia: 'Manage {mediaType}',
|
||||
seasonnumber: 'S{seasonNumber}',
|
||||
seasonepisodenumber: 'S{seasonNumber}E{episodeNumber}',
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ const StatusBadge = ({
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
let mediaLink: string | undefined;
|
||||
let mediaLinkDescription: string | undefined;
|
||||
@@ -84,7 +85,7 @@ const StatusBadge = ({
|
||||
mediaLink = plexUrl;
|
||||
mediaLinkDescription = intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
@@ -106,34 +107,22 @@ const StatusBadge = ({
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipContent =
|
||||
mediaType === 'tv' &&
|
||||
downloadItem.length > 1 &&
|
||||
downloadItem.every(
|
||||
(item) =>
|
||||
item.downloadId && item.downloadId === downloadItem[0].downloadId
|
||||
) ? (
|
||||
<DownloadBlock
|
||||
downloadItem={downloadItem[0]}
|
||||
title={Array.isArray(title) ? title[0] : title}
|
||||
is4k={is4k}
|
||||
/>
|
||||
) : (
|
||||
<ul>
|
||||
{downloadItem.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock
|
||||
downloadItem={status}
|
||||
title={Array.isArray(title) ? title[index] : title}
|
||||
is4k={is4k}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
const tooltipContent = (
|
||||
<ul>
|
||||
{downloadItem.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock
|
||||
downloadItem={status}
|
||||
title={Array.isArray(title) ? title[index] : title}
|
||||
is4k={is4k}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
const badgeDownloadProgress = (
|
||||
<div
|
||||
@@ -188,27 +177,14 @@ const StatusBadge = ({
|
||||
</span>
|
||||
{inProgress && (
|
||||
<>
|
||||
{mediaType === 'tv' &&
|
||||
downloadItem[0].episode &&
|
||||
(downloadItem.length > 1 &&
|
||||
downloadItem.every(
|
||||
(item) =>
|
||||
item.downloadId &&
|
||||
item.downloadId === downloadItem[0].downloadId
|
||||
) ? (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonnumber, {
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
{mediaType === 'tv' && downloadItem[0].episode && (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<Spinner className="ml-1 h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
@@ -254,27 +230,14 @@ const StatusBadge = ({
|
||||
</span>
|
||||
{inProgress && (
|
||||
<>
|
||||
{mediaType === 'tv' &&
|
||||
downloadItem[0].episode &&
|
||||
(downloadItem.length > 1 &&
|
||||
downloadItem.every(
|
||||
(item) =>
|
||||
item.downloadId &&
|
||||
item.downloadId === downloadItem[0].downloadId
|
||||
) ? (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonnumber, {
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
{mediaType === 'tv' && downloadItem[0].episode && (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<Spinner className="ml-1 h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
@@ -320,27 +283,14 @@ const StatusBadge = ({
|
||||
</span>
|
||||
{inProgress && (
|
||||
<>
|
||||
{mediaType === 'tv' &&
|
||||
downloadItem[0].episode &&
|
||||
(downloadItem.length > 1 &&
|
||||
downloadItem.every(
|
||||
(item) =>
|
||||
item.downloadId &&
|
||||
item.downloadId === downloadItem[0].downloadId
|
||||
) ? (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonnumber, {
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
{mediaType === 'tv' && downloadItem[0].episode && (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<Spinner className="ml-1 h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
@@ -360,17 +310,6 @@ const StatusBadge = ({
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case MediaStatus.BLACKLISTED:
|
||||
return (
|
||||
<Tooltip content={mediaLinkDescription}>
|
||||
<Badge badgeType="danger" href={mediaLink}>
|
||||
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
|
||||
status: intl.formatMessage(globalMessages.blacklisted),
|
||||
})}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import BlacklistModal from '@app/components/BlacklistModal';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import StatusBadgeMini from '@app/components/Common/StatusBadgeMini';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import RequestModal from '@app/components/RequestModal';
|
||||
import ErrorCard from '@app/components/TitleCard/ErrorCard';
|
||||
import Placeholder from '@app/components/TitleCard/Placeholder';
|
||||
@@ -15,8 +13,6 @@ import { withProperties } from '@app/utils/typeHelpers';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
MinusCircleIcon,
|
||||
StarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
@@ -24,7 +20,7 @@ import { MediaStatus } from '@server/constants/media';
|
||||
import type { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { MediaType } from '@server/models/Search';
|
||||
import Link from 'next/link';
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import { mutate } from 'swr';
|
||||
@@ -69,7 +65,7 @@ const TitleCard = ({
|
||||
}: TitleCardProps) => {
|
||||
const isTouch = useIsTouch();
|
||||
const intl = useIntl();
|
||||
const { user, hasPermission } = useUser();
|
||||
const { hasPermission } = useUser();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [currentStatus, setCurrentStatus] = useState(status);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
@@ -78,8 +74,6 @@ const TitleCard = ({
|
||||
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
||||
!isAddedToWatchlist
|
||||
);
|
||||
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Just to get the year from the date
|
||||
if (year) {
|
||||
@@ -100,11 +94,6 @@ const TitleCard = ({
|
||||
[]
|
||||
);
|
||||
|
||||
const closeBlacklistModal = useCallback(
|
||||
() => setShowBlacklistModal(false),
|
||||
[]
|
||||
);
|
||||
|
||||
const onClickWatchlistBtn = async (): Promise<void> => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
@@ -177,99 +166,6 @@ const TitleCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onClickHideItemBtn = async (): Promise<void> => {
|
||||
setIsUpdating(true);
|
||||
const topNode = cardRef.current;
|
||||
|
||||
if (topNode) {
|
||||
const res = await fetch('/api/v1/blacklist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tmdbId: id,
|
||||
mediaType,
|
||||
title,
|
||||
user: user?.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 201) {
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(globalMessages.blacklistSuccess, {
|
||||
title,
|
||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'success', autoDismiss: true }
|
||||
);
|
||||
setCurrentStatus(MediaStatus.BLACKLISTED);
|
||||
} else if (res.status === 412) {
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(globalMessages.blacklistDuplicateError, {
|
||||
title,
|
||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'info', autoDismiss: true }
|
||||
);
|
||||
} else {
|
||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
|
||||
setIsUpdating(false);
|
||||
closeBlacklistModal();
|
||||
} else {
|
||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onClickShowBlacklistBtn = async (): Promise<void> => {
|
||||
setIsUpdating(true);
|
||||
const topNode = cardRef.current;
|
||||
|
||||
if (topNode) {
|
||||
const res = await fetch('/api/v1/blacklist/' + id, {
|
||||
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 }
|
||||
);
|
||||
setCurrentStatus(MediaStatus.UNKNOWN);
|
||||
} else {
|
||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
const closeModal = useCallback(() => setShowRequestModal(false), []);
|
||||
|
||||
const showRequestButton = hasPermission(
|
||||
@@ -282,15 +178,10 @@ const TitleCard = ({
|
||||
{ type: 'or' }
|
||||
);
|
||||
|
||||
const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], {
|
||||
type: 'or',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}
|
||||
data-testid="title-card"
|
||||
ref={cardRef}
|
||||
>
|
||||
<RequestModal
|
||||
tmdbId={id}
|
||||
@@ -306,20 +197,6 @@ const TitleCard = ({
|
||||
onUpdating={requestUpdating}
|
||||
onCancel={closeModal}
|
||||
/>
|
||||
<BlacklistModal
|
||||
tmdbId={id}
|
||||
type={
|
||||
mediaType === 'movie'
|
||||
? 'movie'
|
||||
: mediaType === 'collection'
|
||||
? 'collection'
|
||||
: 'tv'
|
||||
}
|
||||
show={showBlacklistModal}
|
||||
onCancel={closeBlacklistModal}
|
||||
onComplete={onClickHideItemBtn}
|
||||
isUpdating={isUpdating}
|
||||
/>
|
||||
<div
|
||||
className={`relative transform-gpu cursor-default overflow-hidden rounded-xl bg-gray-800 bg-cover outline-none ring-1 transition duration-300 ${
|
||||
showDetail
|
||||
@@ -346,7 +223,6 @@ const TitleCard = ({
|
||||
>
|
||||
<div className="absolute inset-0 h-full w-full overflow-hidden">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
alt=""
|
||||
src={
|
||||
@@ -359,7 +235,7 @@ const TitleCard = ({
|
||||
/>
|
||||
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
|
||||
<div
|
||||
className={`pointer-events-none z-40 self-start rounded-full border bg-opacity-80 shadow-md ${
|
||||
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
|
||||
mediaType === 'movie' || mediaType === 'collection'
|
||||
? 'border-blue-500 bg-blue-600'
|
||||
: 'border-purple-600 bg-purple-600'
|
||||
@@ -373,8 +249,8 @@ const TitleCard = ({
|
||||
: intl.formatMessage(globalMessages.tvshow)}
|
||||
</div>
|
||||
</div>
|
||||
{showDetail && currentStatus !== MediaStatus.BLACKLISTED && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{showDetail && (
|
||||
<>
|
||||
{toggleWatchlist ? (
|
||||
<Button
|
||||
buttonType={'ghost'}
|
||||
@@ -393,49 +269,15 @@ const TitleCard = ({
|
||||
<MinusCircleIcon className={'h-3'} />
|
||||
</Button>
|
||||
)}
|
||||
{showHideButton &&
|
||||
currentStatus !== MediaStatus.PROCESSING &&
|
||||
currentStatus !== MediaStatus.AVAILABLE &&
|
||||
currentStatus !== MediaStatus.PARTIALLY_AVAILABLE &&
|
||||
currentStatus !== MediaStatus.PENDING && (
|
||||
<Button
|
||||
buttonType={'ghost'}
|
||||
className="z-40"
|
||||
buttonSize={'sm'}
|
||||
onClick={() => setShowBlacklistModal(true)}
|
||||
>
|
||||
<EyeSlashIcon className={'h-3'} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showDetail &&
|
||||
showHideButton &&
|
||||
currentStatus == MediaStatus.BLACKLISTED && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(
|
||||
globalMessages.removefromBlacklist
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
buttonType={'ghost'}
|
||||
className="z-40"
|
||||
buttonSize={'sm'}
|
||||
onClick={() => onClickShowBlacklistBtn()}
|
||||
>
|
||||
<EyeIcon className={'h-3'} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="pointer-events-none z-40 flex">
|
||||
<StatusBadgeMini
|
||||
status={currentStatus}
|
||||
inProgress={inProgress}
|
||||
shrink
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none z-40 flex items-center">
|
||||
<StatusBadgeMini
|
||||
status={currentStatus}
|
||||
inProgress={inProgress}
|
||||
shrink
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,7 @@ import RTAudFresh from '@app/assets/rt_aud_fresh.svg';
|
||||
import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
|
||||
import RTFresh from '@app/assets/rt_fresh.svg';
|
||||
import RTRotten from '@app/assets/rt_rotten.svg';
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
||||
import BlacklistModal from '@app/components/BlacklistModal';
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
@@ -39,33 +37,24 @@ import {
|
||||
ArrowRightCircleIcon,
|
||||
CogIcon,
|
||||
ExclamationTriangleIcon,
|
||||
EyeSlashIcon,
|
||||
FilmIcon,
|
||||
PlayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
MinusCircleIcon,
|
||||
StarIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||
import type { RTRating } from '@server/api/rating/rottentomatoes';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import { IssueStatus } from '@server/constants/issue';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { Crew } from '@server/models/common';
|
||||
import type { TvDetails as TvDetailsType } from '@server/models/Tv';
|
||||
import { countries } from 'country-flag-icons';
|
||||
import 'country-flag-icons/3x2/flags.css';
|
||||
import getConfig from 'next/config';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages('components.TvDetails', {
|
||||
@@ -100,12 +89,6 @@ const messages = defineMessages('components.TvDetails', {
|
||||
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
|
||||
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
||||
tmdbuserscore: 'TMDB 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 TvDetailsProps {
|
||||
@@ -123,14 +106,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
router.query.manage == '1' ? true : false
|
||||
);
|
||||
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
||||
!tv?.onUserWatchlist
|
||||
);
|
||||
const [isBlacklistUpdating, setIsBlacklistUpdating] =
|
||||
useState<boolean>(false);
|
||||
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
|
||||
const { addToast } = useToasts();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -160,11 +136,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
setShowManager(router.query.manage == '1' ? true : false);
|
||||
}, [router.query.manage]);
|
||||
|
||||
const closeBlacklistModal = useCallback(
|
||||
() => setShowBlacklistModal(false),
|
||||
[]
|
||||
);
|
||||
|
||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||
mediaUrl: data?.mediaInfo?.mediaUrl,
|
||||
mediaUrl4k: data?.mediaInfo?.mediaUrl4k,
|
||||
@@ -308,7 +279,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
?.flatrate ?? [];
|
||||
|
||||
function getAvalaibleMediaServerName() {
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||
}
|
||||
|
||||
@@ -320,147 +291,17 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
}
|
||||
|
||||
function getAvalaible4kMediaServerName() {
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
|
||||
return intl.formatMessage(messages.play4k, { mediaServerName: 'Emby' });
|
||||
}
|
||||
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
|
||||
return intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' });
|
||||
}
|
||||
|
||||
return intl.formatMessage(messages.play, { 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: tv?.id,
|
||||
mediaType: MediaType.TV,
|
||||
title: tv?.name,
|
||||
}),
|
||||
});
|
||||
|
||||
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: tv?.name,
|
||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'success', autoDismiss: true }
|
||||
);
|
||||
}
|
||||
|
||||
setIsUpdating(false);
|
||||
setToggleWatchlist((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const onClickDeleteWatchlistBtn = async (): Promise<void> => {
|
||||
setIsUpdating(true);
|
||||
|
||||
const res = await fetch('/api/v1/watchlist/' + tv?.id, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
addToast(intl.formatMessage(messages.watchlistError), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
|
||||
setIsUpdating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(messages.watchlistDeleted, {
|
||||
title: tv?.name,
|
||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'info', autoDismiss: true }
|
||||
);
|
||||
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: tv?.id,
|
||||
mediaType: 'tv',
|
||||
title: tv?.name,
|
||||
user: user?.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 201) {
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(globalMessages.blacklistSuccess, {
|
||||
title: tv?.name,
|
||||
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: tv?.name,
|
||||
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 (
|
||||
<div
|
||||
className="media-page"
|
||||
@@ -471,7 +312,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
{data.backdropPath && (
|
||||
<div className="media-page-bg-image">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
@@ -488,14 +328,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
</div>
|
||||
)}
|
||||
<PageTitle title={data.name} />
|
||||
<BlacklistModal
|
||||
tmdbId={data.id}
|
||||
type="tv"
|
||||
show={showBlacklistModal}
|
||||
onCancel={closeBlacklistModal}
|
||||
onComplete={onClickHideItemBtn}
|
||||
isUpdating={isBlacklistUpdating}
|
||||
/>
|
||||
<IssueModal
|
||||
onCancel={() => setShowIssueModal(false)}
|
||||
show={showIssueModal}
|
||||
@@ -528,7 +360,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={
|
||||
data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
@@ -602,61 +433,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
</span>
|
||||
</div>
|
||||
<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} />
|
||||
<RequestButton
|
||||
mediaType="tv"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import getConfig from 'next/config';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
@@ -36,6 +36,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const { addToast } = useToasts();
|
||||
const [isImporting, setImporting] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
@@ -55,6 +56,14 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
`/api/v1/user?take=${children}`
|
||||
);
|
||||
|
||||
data?.forEach((user, pos) => {
|
||||
if (
|
||||
existingUsers?.results.some((data) => data.jellyfinUserId === user.id)
|
||||
) {
|
||||
data?.splice(pos, 1);
|
||||
}
|
||||
});
|
||||
|
||||
const importUsers = async () => {
|
||||
setImporting(true);
|
||||
|
||||
@@ -80,9 +89,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
userCount: createdUsers.length,
|
||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||
}),
|
||||
{
|
||||
autoDismiss: true,
|
||||
@@ -97,9 +104,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
addToast(
|
||||
intl.formatMessage(messages.importfromJellyfinerror, {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||
}),
|
||||
{
|
||||
autoDismiss: true,
|
||||
@@ -137,9 +142,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
loading={!data && !error}
|
||||
title={intl.formatMessage(messages.importfromJellyfin, {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||
})}
|
||||
onOk={() => {
|
||||
importUsers();
|
||||
@@ -156,8 +159,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.newJellyfinsigninenabled, {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
strong: (msg: React.ReactNode) => (
|
||||
@@ -207,72 +209,64 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700 bg-gray-600">
|
||||
{data
|
||||
?.filter(
|
||||
(user) =>
|
||||
!existingUsers?.results.some(
|
||||
(u) => u.jellyfinUserId === user.id
|
||||
)
|
||||
)
|
||||
.map((user) => (
|
||||
<tr key={`user-${user.id}`}>
|
||||
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
|
||||
{data?.map((user) => (
|
||||
<tr key={`user-${user.id}`}>
|
||||
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={isSelectedUser(user.id)}
|
||||
onClick={() => toggleUser(user.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
toggleUser(user.id);
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
|
||||
>
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={isSelectedUser(user.id)}
|
||||
onClick={() => toggleUser(user.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
toggleUser(user.id);
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isSelectedUser(user.id)
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-gray-800'
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isSelectedUser(user.id)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
||||
<div className="flex items-center">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
className="h-10 w-10 flex-shrink-0 rounded-full"
|
||||
src={user.thumb}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
<div className="ml-4">
|
||||
<div className="text-base font-bold leading-5">
|
||||
{user.username}
|
||||
</div>
|
||||
{/* {user.username &&
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isSelectedUser(user.id)
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-gray-800'
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isSelectedUser(user.id)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
className="h-10 w-10 flex-shrink-0 rounded-full"
|
||||
src={user.thumb}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
<div className="ml-4">
|
||||
<div className="text-base font-bold leading-5">
|
||||
{user.username}
|
||||
</div>
|
||||
{/* {user.username &&
|
||||
user.username.toLowerCase() !==
|
||||
user.email && (
|
||||
<div className="text-sm leading-5 text-gray-300">
|
||||
{user.email}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -284,9 +278,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.noJellyfinuserstoimport, {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||
})}
|
||||
type="info"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Header from '@app/components/Common/Header';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
@@ -29,6 +28,8 @@ import { MediaServerType } from '@server/constants/server';
|
||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import { hasPermission } from '@server/lib/permissions';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import getConfig from 'next/config';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -89,6 +90,7 @@ const UserList = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const { addToast } = useToasts();
|
||||
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
|
||||
const [currentSort, setCurrentSort] = useState<Sort>('displayname');
|
||||
@@ -533,8 +535,7 @@ const UserList = () => {
|
||||
>
|
||||
<InboxArrowDownIcon />
|
||||
<span>
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? intl.formatMessage(messages.importfrommediaserver, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
@@ -633,8 +634,7 @@ const UserList = () => {
|
||||
href={`/users/${user.id}`}
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
>
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
@@ -690,7 +690,7 @@ const UserList = () => {
|
||||
<Badge badgeType="default">
|
||||
{intl.formatMessage(messages.localuser)}
|
||||
</Badge>
|
||||
) : user.userType === UserType.EMBY ? (
|
||||
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(messages.mediaServerUser, {
|
||||
mediaServerName: 'Emby',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import type { User } from '@app/hooks/useUser';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
@@ -42,8 +42,7 @@ const ProfileHeader = ({ user, isSettingsPage }: ProfileHeaderProps) => {
|
||||
<div className="flex items-end justify-items-end space-x-5">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
<Image
|
||||
className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700"
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
|
||||
@@ -14,9 +14,9 @@ import globalMessages from '@app/i18n/globalMessages';
|
||||
import ErrorPage from '@app/pages/_error';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import getConfig from 'next/config';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -43,7 +43,6 @@ const messages = defineMessages(
|
||||
user: 'User',
|
||||
toastSettingsSuccess: 'Settings saved successfully!',
|
||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||
toastSettingsFailureEmail: 'This email is already taken!',
|
||||
region: 'Discover Region',
|
||||
regionTip: 'Filter content by regional availability',
|
||||
originallanguage: 'Discover Language',
|
||||
@@ -70,6 +69,7 @@ const messages = defineMessages(
|
||||
|
||||
const UserGeneralSettings = () => {
|
||||
const intl = useIntl();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const { addToast } = useToasts();
|
||||
const { locale, setLocale } = useLocale();
|
||||
const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false);
|
||||
@@ -180,7 +180,7 @@ const UserGeneralSettings = () => {
|
||||
watchlistSyncTv: values.watchlistSyncTv,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
if (currentUser?.id === user?.id && setLocale) {
|
||||
setLocale(
|
||||
@@ -195,24 +195,10 @@ const UserGeneralSettings = () => {
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
if (errorData?.message === ApiErrorCode.InvalidEmail) {
|
||||
addToast(intl.formatMessage(messages.toastSettingsFailureEmail), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} else {
|
||||
addToast(intl.formatMessage(messages.toastSettingsFailure), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastSettingsFailure), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
revalidateUser();
|
||||
@@ -243,7 +229,7 @@ const UserGeneralSettings = () => {
|
||||
<Badge badgeType="default">
|
||||
{intl.formatMessage(messages.localuser)}
|
||||
</Badge>
|
||||
) : user?.userType === UserType.EMBY ? (
|
||||
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(messages.mediaServerUser, {
|
||||
mediaServerName: 'Emby',
|
||||
|
||||
@@ -55,16 +55,6 @@ const globalMessages = defineMessages('i18n', {
|
||||
noresults: 'No results.',
|
||||
open: 'Open',
|
||||
resolved: 'Resolved',
|
||||
blacklist: 'Blacklist',
|
||||
blacklisted: 'Blacklisted',
|
||||
blacklistSuccess: '<strong>{title}</strong> was successfully blacklisted.',
|
||||
blacklistError: 'Something went wrong try again.',
|
||||
blacklistDuplicateError:
|
||||
'<strong>{title}</strong> has already been blacklisted.',
|
||||
removeFromBlacklistSuccess:
|
||||
'<strong>{title}</strong> was successfully removed from the Blacklist.',
|
||||
addToBlacklist: 'Add to Blacklist',
|
||||
removefromBlacklist: 'Remove from Blacklist',
|
||||
});
|
||||
|
||||
export default globalMessages;
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
"components.Discover.TvGenreSlider.tvgenres": "Gèneres de Sèries",
|
||||
"components.Discover.TvGenreList.seriesgenres": "Gèneres de Sèries",
|
||||
"components.Discover.StudioSlider.studios": "Estudis",
|
||||
"components.Discover.NetworkSlider.networks": "Emissors",
|
||||
"components.Discover.NetworkSlider.networks": "Plataformes",
|
||||
"components.Discover.MovieGenreSlider.moviegenres": "Gèneres de Pel·lícules",
|
||||
"components.Discover.MovieGenreList.moviegenres": "Gèneres de Pel·lícules",
|
||||
"components.Discover.DiscoverTvLanguage.languageSeries": "Sèries en {language}",
|
||||
@@ -397,7 +397,7 @@
|
||||
"components.TvDetails.originaltitle": "Títol original",
|
||||
"components.TvDetails.originallanguage": "Idioma original",
|
||||
"components.TvDetails.nextAirDate": "Pròxima data d'emissió",
|
||||
"components.TvDetails.network": "{networkCount, plural, one {Emissor} other {Emissors}}",
|
||||
"components.TvDetails.network": "{networkCount, plural, one {Plataforma} other {Plataformes}}",
|
||||
"components.TvDetails.firstAirDate": "Primera data d'emissió",
|
||||
"components.TvDetails.episodeRuntimeMinutes": "{runtime} minuts",
|
||||
"components.TvDetails.episodeRuntime": "Duració de l'episodi",
|
||||
@@ -494,7 +494,7 @@
|
||||
"components.Settings.SonarrModal.validationNameRequired": "Heu de proporcionar un nom de servidor",
|
||||
"components.Settings.SonarrModal.validationLanguageProfileRequired": "Heu de seleccionar un perfil d'idioma",
|
||||
"components.Settings.SonarrModal.validationHostnameRequired": "Heu de proporcionar un nom d’amfitrió o una adreça IP vàlides",
|
||||
"components.Settings.SonarrModal.validationBaseUrlTrailingSlash": "L'URL base no pot acabar amb una barra inclinada final",
|
||||
"components.Settings.SonarrModal.validationBaseUrlTrailingSlash": "L'URL base no ha d'acabar amb una barra inclinada final",
|
||||
"components.Settings.SonarrModal.validationBaseUrlLeadingSlash": "L'URL base ha de tenir una barra inclinada",
|
||||
"components.Settings.SonarrModal.validationApplicationUrlTrailingSlash": "L'URL no pot acabar amb una barra inclinada final",
|
||||
"components.Settings.SonarrModal.validationApplicationUrl": "Heu de proporcionar un URL vàlid",
|
||||
@@ -1135,7 +1135,7 @@
|
||||
"components.Discover.CreateSlider.needresults": "Cal tenir almenys 1 resultat.",
|
||||
"components.Discover.CreateSlider.nooptions": "Sense resultats.",
|
||||
"components.Discover.CreateSlider.providetmdbgenreid": "Proporciona un ID de categoria TMDB",
|
||||
"components.Discover.CreateSlider.providetmdbnetwork": "Proporciona l'ID d'emissor TMDB",
|
||||
"components.Discover.CreateSlider.providetmdbnetwork": "Proporciona l'ID de la plataforma TMDB",
|
||||
"components.Discover.CreateSlider.providetmdbstudio": "Proporciona l'ID d'estudi TMDB",
|
||||
"components.Discover.CreateSlider.searchGenres": "Cercar per gènere…",
|
||||
"components.Discover.CreateSlider.searchKeywords": "Cercar per paraules clau…",
|
||||
@@ -1167,7 +1167,7 @@
|
||||
"components.Discover.networks": "Emissors",
|
||||
"components.Discover.resetwarning": "Restablir tots els controls lliscants al valor predeterminat. Això també suprimirà els controls lliscants personalitzats!",
|
||||
"components.Discover.tmdbmoviekeyword": "Paraula clau de pel·lícula TMDB",
|
||||
"components.Discover.tmdbnetwork": "Emissors TMDB",
|
||||
"components.Discover.tmdbnetwork": "Plataformes TMDB",
|
||||
"components.Discover.FilterSlideover.tmdbuserscore": "Puntuació d'usuaris TMDB",
|
||||
"components.Discover.tvgenres": "Gèneres de sèries",
|
||||
"components.Discover.DiscoverTvKeyword.keywordSeries": "Sèries {keywordTitle}",
|
||||
@@ -1241,11 +1241,18 @@
|
||||
"components.Settings.SettingsJobsCache.availability-sync": "Sincronització de disponibilitat de contingut",
|
||||
"components.Discover.tmdbmoviestreamingservices": "Serveis de transmissió de pel·lícules TMDB",
|
||||
"components.Discover.tmdbtvstreamingservices": "Serveis de transmissió de TV TMDB",
|
||||
"components.Layout.UserWarnings.emailRequired": "És requereix un n correu electrònic.",
|
||||
"components.Layout.UserWarnings.passwordRequired": "Es requereix una contrasenya.",
|
||||
"components.Login.description": "Com que és la primera vegada que inicieu sessió a {applicationName}, es necessita afegir un correu electrònic vàlid.",
|
||||
"components.Discover.FilterSlideover.tmdbuservotecount": "Recompte de vots d'usuaris de TMDB",
|
||||
"components.Discover.FilterSlideover.voteCount": "Nombre de vots entre {minValue} i {maxValue}",
|
||||
"components.Layout.UserWarnings.emailInvalid": "El correu electrònic no és vàlid.",
|
||||
"components.Login.credentialerror": "El nom d'usuari o la contrasenya són incorrectes."
|
||||
"components.Discover.FilterSlideover.tmdbuservotecount": "Recompte de vots dels usuaris de TMDB",
|
||||
"components.Discover.FilterSlideover.voteCount": "Número de vots entre {minValue} i {maxValue}",
|
||||
"components.UserProfile.UserSettings.UserNotificationSettings.sound": "So per a les notificacions",
|
||||
"components.Settings.Notifications.NotificationsPushover.deviceDefault": "Dispositiu per defecte",
|
||||
"components.Settings.Notifications.NotificationsPushover.sound": "So per a les notificacions",
|
||||
"components.Settings.SonarrModal.animeSeriesType": "Tipus d'Anime",
|
||||
"components.Settings.SonarrModal.seriesType": "Tipus de sèrie",
|
||||
"components.Settings.SonarrModal.tagRequests": "Sol·licituds d'etiquetes",
|
||||
"components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Dispositiu per defecte",
|
||||
"i18n.collection": "Col·lecció",
|
||||
"components.MovieDetails.imdbuserscore": "Puntuació dels usuaris de IMDB",
|
||||
"components.Settings.RadarrModal.tagRequests": "Sol·licituds d'etiqueta",
|
||||
"components.Settings.RadarrModal.tagRequestsInfo": "Automàticament afegeix una etiqueta addicional amb el nom d'usuari i nom complet del sol·licitant",
|
||||
"components.Settings.SonarrModal.tagRequestsInfo": "Automàticament afegeix una etiqueta addicional amb el nom d'usuari i nom complet del sol·licitant"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user