1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-16 21:15:33 -04:00

Compare commits

...

106 Commits

Author SHA1 Message Date
Bogdan
0f1cf21c39 Fixed: Calculate custom formats after setting user-chosen attributes in manual import
Necessary to calculate the correct scoring post-manual import for those custom formats that are dependent on other attributes like for example the quality.
2024-06-27 03:32:45 +03:00
Bogdan
92a19a1a81 Fixed: Switch to discover/movie for TMDB Keyword list 2024-06-27 00:51:20 +03:00
Bogdan
54965cfa6f Bump mac image to 12 2024-06-26 23:49:58 +03:00
Mark McDowall
14f27cf2b6 Fixed: Limit Queue maximum page size to 200
(cherry picked from commit 6de536a7adcb604ec057d37873585fa665567437)
2024-06-26 23:21:35 +03:00
Mark McDowall
a607f167f4 Fixed: Reprocessing items that were previously blocked during importing
(cherry picked from commit bce848facf8aeaeac6a1d59c92941d00589034a4)
2024-06-26 23:21:09 +03:00
Servarr
29449e83f9 Automated API Docs update 2024-06-26 04:26:53 +03:00
Mark McDowall
bb4e185644 New: Remove websites in parentheses before parsing
(cherry picked from commit ea4fe392a0cc4774bb28c969fb3903db264c8d6c)

Closes #10114
2024-06-26 04:18:00 +03:00
Mark McDowall
085b1db77f New: Ability to select Plex Media Server from plex.tv
(cherry picked from commit 4c622fd41289cd293a68a6a9f6b8da2a086edecb)

Closes #10110
2024-06-26 04:07:24 +03:00
Mark McDowall
7bdb3e437d New: Improve UI status when downloads cannot be imported automatically
(cherry picked from commit 6d5ff9c4d6993d16848980aea499a45b1b51d95c)

Closes #10107
2024-06-26 03:57:29 +03:00
Mark McDowall
fcb0d8a930 New: Ignore Deluge torrents without a title
(cherry picked from commit a0d29331341320268552660658b949179c963793)
2024-06-26 02:49:06 +03:00
Bogdan
7dc64c595c Fixed: Exclude invalid releases from Newznab and Torznab parsers
(cherry picked from commit fb060730c7d52cd342484dc68595698a9430df7b)
2024-06-26 02:48:54 +03:00
Weblate
9a2b4bc81d Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Taylan Tatlı <taylantatli90@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_TW/
Translation: Servarr/Radarr
2024-06-23 22:53:26 +03:00
Bogdan
f228841dc7 New: Release dates as columns for Missing/Cutoff Unmet 2024-06-22 02:59:34 +03:00
Bogdan
02be9cf825 Bump version to 5.8.0 2024-06-20 17:01:16 +03:00
Weblate
8809c207bb Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translation: Servarr/Radarr
2024-06-18 20:13:44 +03:00
Mark McDowall
1be2cded74 Fixed: Importing from IMDb list
(cherry picked from commit f8e81396d409362da359b3fde671ad826e5c68e3)

Closes #10090
2024-06-18 19:48:08 +03:00
Bogdan
0a189d00ef New: Display stats for delete movies modal
Closes #10093
2024-06-18 19:42:09 +03:00
Bogdan
5fc63ecb3f New: Ignore inaccessible folders when getting folders
(cherry picked from commit a30e9da7672a202cb9e9188cf106afc34a5d0361)
2024-06-18 06:55:13 +03:00
Bogdan
3a74393d05 Fixed: Ensure TMDb import lists are paginated 2024-06-16 03:31:28 +03:00
Mark McDowall
4cbf5cfc57 Fixed: Adding movies with unknown items in queue 2024-06-12 19:02:26 +03:00
Weblate
797142d6f3 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translation: Servarr/Radarr
2024-06-11 08:04:09 +03:00
Servarr
2a472c50c1 Automated API Docs update 2024-06-11 08:03:37 +03:00
Mark McDowall
a12ff68fbd Fixed: Skip invalid movie paths during validation
(cherry picked from commit 378fedcd9dcb0fe07585727dd7d9e5e765c863c0)

Closes #10079
2024-06-11 07:40:11 +03:00
Bogdan
194926c7dd Ignore Grabbed from API docs
Run application in docs.sh specific to platform

(cherry picked from commit c331c8bd119fa9f85a53e96db04f541b2d90bbd3)

Closes #10082
2024-06-11 07:34:45 +03:00
Bogdan
7dee5bb689 Rename Sonarr to Radarr 2024-06-11 07:31:07 +03:00
Mark McDowall
9b24dab71b Fixed: Improve error messaging if config file isn't formatted correctly
(cherry picked from commit 52b72925f9d42c896144dde3099dc19c397327b0)
2024-06-11 07:16:02 +03:00
Bogdan
62e1c02fe2 Fixed: Ignore case when resolving indexer by name in release push
(cherry picked from commit a90ab1a8fd50126d7f60eaa684eac1e0cd98e2b7)
2024-06-11 07:15:50 +03:00
Bogdan
99b3d61862 Fixed: Ignore case for name validation in providers
(cherry picked from commit 0edc5ba99a15c5f80305b387a053f35fc3f6e51b)
2024-06-11 07:15:33 +03:00
Bogdan
bd905567de Fixed: Map covers to local for grabbed movies 2024-06-10 14:23:55 +03:00
Bogdan
a8eea20d69 Fallback to remote url for backdrop image 2024-06-10 14:21:50 +03:00
tsuereth
69ad0caf40 Fixed: Avoid NullRef for Movie Resources with a null tags field 2024-06-10 13:37:57 +03:00
Bogdan
8a5c0ffd18 New: Refresh cache for tracked queue on movie add 2024-06-06 12:32:39 +03:00
Bogdan
c8b409ed0b Added some missing indexes to database 2024-06-03 17:38:58 +03:00
Weblate
c5bcb13f63 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: AlbertCoolGuy <Albert.rosenstand@gmail.com>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Nermendis <nermendis@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Yi Cao <caoyi06@qq.com>
Co-authored-by: ewenlau <eliaswendland@free.fr>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: mm519897405 <baiya@vip.qq.com>
Co-authored-by: nicolhac <hacheyn@me.com>
Co-authored-by: r0bertreh <Robert.reh@live.de>
Co-authored-by: thegamingcat13 <sandervanbeek2004@gmail.com>
Co-authored-by: topnew <sznetim@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_TW/
Translation: Servarr/Radarr
2024-06-03 09:10:15 +03:00
Bogdan
80de711654 Bump Microsoft.NET.Test.Sdk, SharpZipLib and Polly 2024-06-03 08:51:02 +03:00
Bogdan
3fb558411e Include year in page title for movie details 2024-06-03 08:15:44 +03:00
Servarr
98384ab390 Automated API Docs update 2024-05-31 14:30:17 +03:00
Bogdan
0c654377f4 Fixed: Manual Interaction Required with possible null movie
Prevent a NullRef when the notification is sent due to an invalid movie title

Fixes #10053
2024-05-31 13:50:49 +03:00
Bogdan
e8c925274a Implement equality checks for providers 2024-05-22 03:51:11 +03:00
Bogdan
320bfeec16 Fixed: Trimming slashes from UrlBase when using environment variable
(cherry picked from commit d7ceb11a64c3926f35aabf67c935680cf031bd0e)
2024-05-22 03:19:25 +03:00
Bogdan
638f92495c Bump version to 5.7.0 2024-05-14 20:18:27 +03:00
Bogdan
077b041d3f Fixed: Revert "Validate that folders in paths don't start or end with a space"
This reverts commit 0d0575f3a9.
2024-05-14 18:08:38 +03:00
Bogdan
ff3dd3ae42 Tests for Wanted pages 2024-05-14 18:05:01 +03:00
Bogdan
2e3beddcbc Fixed: Sorting by movie titles in Missing/Cutoff Unmet under Postgres 2024-05-14 15:56:49 +03:00
Servarr
dc068bbf3d Automated API Docs update 2024-05-14 03:07:05 +03:00
Bogdan
7a303c1ebf Remove not implemented endpoints from API docs 2024-05-14 02:53:51 +03:00
Bogdan
152f50a1ef New: Wanted Cutoff/Missing 2024-05-14 02:53:51 +03:00
Bogdan
9798202589 Add missing translation for External 2024-05-14 02:53:51 +03:00
Bogdan
7969776339 Rename file for getMovieStatusDetails 2024-05-14 02:53:51 +03:00
Bogdan
288982d7bd Bump Npgsql to 7.0.7 2024-05-13 15:14:57 +03:00
Servarr
d39a3ade5b Automated API Docs update 2024-05-12 22:29:56 +03:00
Bogdan
1fc6e88bc4 New: Add isExisting flag for movies in collections API 2024-05-12 22:20:13 +03:00
Bogdan
e8e1841e6c New: No Release Dates availability message
Co-authored-by: bakerboy448 <55419169+bakerboy448@users.noreply.github.com>
2024-05-12 17:16:15 +03:00
Bogdan
d17eb4f33f Bump version to 5.6.0 2024-05-12 16:28:32 +03:00
Bogdan
685f462959 New: Include trending and popular options for Discover Movies 2024-05-11 16:29:42 +03:00
Servarr
7be8a34130 Automated API Docs update 2024-05-10 21:30:13 +03:00
Ivan Sanz Carasa
886711b496 New: LanguageId filter added to all movie endpoint 2024-05-10 20:54:57 +03:00
Servarr
5185e037da Automated API Docs update 2024-05-10 20:51:41 +03:00
Mark McDowall
38e7e37d57 Refactor movie tags for CustomScript, Webhook and Notifiarr events
(cherry picked from commit cc0a284660f139d5f47b27a2c389973e5e888587)

Closes #10003
2024-05-10 16:15:51 +03:00
Stevie Robinson
190c4c5893 New: Blocklist Custom Filters
(cherry picked from commit f81bb3ec1945d343dd0695a2826dac8833cb6346)

Closes #9997
2024-05-10 16:04:03 +03:00
Mark McDowall
0ec18ce4b3 New: Parse 480i Bluray/Remux as Bluray 480p
(cherry picked from commit 627b2a4289ecdd5558d37940624289708e01e10a)

Closes #10010
2024-05-10 14:59:24 +03:00
Weblate
a08575b7bc Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Michael5564445 <michaelvelosk@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translation: Servarr/Radarr
2024-05-10 14:00:54 +03:00
Bogdan
556cc885ec Refactor PasswordInput to use type password
(cherry picked from commit c7c1e3ac9e5bffd4d92298fed70916e3808613fd)
2024-05-10 14:00:22 +03:00
Bogdan
586c0c6e13 Fixed: Notifications with only On Rename enabled 2024-05-10 00:47:31 +03:00
Bogdan
cec569461d Fixed: Text color for inputs on login page 2024-05-09 20:58:49 +03:00
Mark McDowall
8b79b5afbf New: Dark theme for login screen
(cherry picked from commit cae134ec7b331d1c906343716472f3d043614b2c)

Closes #9998
2024-05-09 20:58:49 +03:00
Mickaël Thomas
cd4552ce6f New: Support stoppedUP and stoppedDL states from qBittorrent
(cherry picked from commit 73a4bdea5247ee87e6bbae95f5325e1f03c88a7f)

Closes #9995
2024-05-09 20:58:49 +03:00
Bogdan
256439304b Use number input for seed ratio
(cherry picked from commit 1eddf3a152fae04142263c02a3e3b317ff2feeb2)

Plus translations

Closes #10000
2024-05-09 20:58:49 +03:00
Bogdan
bb44fbc362 New: Root folder exists validation for import lists
Also moved the AppendArgument to avoid cases like `Invalid Path: '{path}'`.
2024-05-09 20:58:49 +03:00
Servarr
cd401f72f5 Automated API Docs update 2024-05-08 04:31:35 +03:00
Bogdan
c9624e7550 Fixed: Ignore invalid movie tags when writing XBMC metadata
Co-authored-by: Mark McDowall <mark@mcdowall.ca>

Fixes #9984
2024-05-08 04:05:34 +03:00
Bogdan
649702eaca Fixed: Indexer flags for torrent release pushes
(cherry picked from commit 47ba002806fe2c2004a649aa193ae318343a84e4)
2024-05-07 18:11:58 +03:00
Servarr
1c52f0f5bd Automated API Docs update 2024-05-06 23:54:09 +03:00
Bogdan
dff85dc1f3 New: Display excluded label for movies in collections 2024-05-06 23:19:15 +03:00
Bogdan
1090aeff75 Fixed: Ignore exclusions in missing movies for collections
Fixes #9966
2024-05-06 23:18:02 +03:00
Jared
086a0addba New: Config file setting to disable log database (#9943)
Co-authored-by: sillock1 <jprest97@gmail.com>
2024-05-06 21:51:19 +03:00
Bogdan
8b6cf34ce4 Fixed: Parsing long downloading/seeding values from Transmission
Fixes #9987
2024-05-06 21:26:36 +03:00
Jared
7f03a916f1 New: Optionally use Environment Variables for settings in config.xml (#9985)
Co-authored-by: sillock1 <jprest97@gmail.com>
2024-05-05 22:32:07 +03:00
Mika
3a6d603a9e Add file-count for Transmission RPC
(cherry picked from commit 23c741fd001582fa363c2723eff9facd3091618b)

Closes #9973
2024-05-05 13:03:21 +03:00
Weblate
cd2c7dc7fb Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Michael5564445 <michaelvelosk@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translation: Servarr/Radarr
2024-05-05 12:50:06 +03:00
Bogdan
f1d76c3483 Fix translations for SSL settings 2024-05-05 12:38:58 +03:00
Stevie Robinson
39eac4b5ad Add missing translation key
(cherry picked from commit 8be8c7f89cf4d40bee941c5ce768aa1a74ebe398)
2024-05-05 12:27:53 +03:00
Mark McDowall
71e1003358 Forward X-Forwarded-Host header
(cherry picked from commit 3fbe4361386e9fb8dafdf82ad9f00f02bec746cc)
2024-05-05 12:27:10 +03:00
Bogdan
89b6a5d51f Bump version to 5.5.3 2024-05-05 12:26:22 +03:00
Bogdan
711637c448 Fixed: Initialize databases after app folder migrations 2024-05-05 00:57:46 +03:00
Bruno Garcia
2677d25980 Update Sentry SDK add features
Co-authored-by: Stefan Jandl <reg@bitfox.at>
(cherry picked from commit 6377c688fc7b35749d608bf62796446bb5bcb11b)
2024-05-01 23:27:51 +03:00
Bogdan
56639bcd42 Fix translations for SSL settings 2024-04-30 12:41:38 +03:00
Bogdan
1ed62b9ced Use newer Node.js task for in pipelines 2024-04-29 14:40:14 +03:00
Servarr
a596dda253 Automated API Docs update 2024-04-29 14:40:03 +03:00
Bogdan
c0b354039d Parameter binding for API requests 2024-04-29 01:19:25 +03:00
Bogdan
3b5078d117 Fixed: Delay profiles reordering 2024-04-29 01:18:40 +03:00
Bogdan
db1fee8d8a New: Use absolute timestamps for movie history 2024-04-28 20:22:32 +03:00
Mark McDowall
0d0575f3a9 New: Validate that folders in paths don't start or end with a space
(cherry picked from commit 316b5cbf75b45ef9a25f96ce1f2fbed25ad94296)

Closes #9958
2024-04-28 13:31:07 +03:00
Stevie Robinson
2d82347a66 New: Don't initially select 0 byte files in Interactive Import
(cherry picked from commit 04bd535cfca5e25c6a2d5417c6f18d5bf5180f67)

Closes #9960
2024-04-28 13:27:28 +03:00
Bogdan
25838df550 Fixed: Limit titles in task name to 10 movies
(cherry picked from commit c81ae6546118e954e481894d0b3fa6e9a20359c7)

Closes #9961
2024-04-28 13:22:25 +03:00
Mark McDowall
b3a8b99f9a Fixed: Improve paths longer than 256 on Windows failing to hardlink
(cherry picked from commit a97fbcc40a6247bf59678425cf460588fd4dbecd)
2024-04-28 13:19:14 +03:00
Christopher
93a852841f New: Remove qBitorrent torrents that reach inactive seeding time
(cherry picked from commit d738035fed859eb475051f3df494b9c975a42e82)
2024-04-28 13:18:58 +03:00
Bogdan
ead1ec43be Bump version to 5.5.2 2024-04-28 12:55:35 +03:00
Weblate
04b6dd44cb Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <arnaudthommeray+github@ik.me>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Mailme Dashite <mailmedashite@protonmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: aghus <aghus.m@outlook.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: maodun96 <435795439@qq.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2024-04-27 21:13:22 +03:00
Bogdan
3db78079f3 Fixed: Retrying download on not suppressed HTTP errors 2024-04-25 17:22:49 +03:00
Bogdan
c8a6b9f565 Database corruption message linking to wiki 2024-04-25 11:29:26 +03:00
Bogdan
811cafd9ae Bump dotnet to 6.0.29 2024-04-22 08:08:41 +03:00
fireph
ac7039d651 New: Footnote to indicate some renaming tokens support truncation
(cherry picked from commit 7fc3bebc91db217a1c24ab2d01ebbc5bf03c918e)

Closes #9905
2024-04-21 18:36:51 +03:00
Bogdan
a2d11cf684 Bump typescript eslint plugin and parser 2024-04-21 12:40:23 +03:00
Bogdan
cc32635f6f Bump frontend dependencies 2024-04-21 10:31:56 +03:00
Bogdan
10f9cb64ac Bump version to 5.5.1 2024-04-21 09:16:33 +03:00
346 changed files with 11458 additions and 4294 deletions

View File

@@ -9,18 +9,18 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '5.5.0'
majorVersion: '5.8.0'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
sentryOrg: 'servarr'
sentryUrl: 'https://sentry.servarr.com'
dotnetVersion: '6.0.417'
dotnetVersion: '6.0.421'
nodeVersion: '20.X'
innoVersion: '6.2.2'
windowsImage: 'windows-2022'
linuxImage: 'ubuntu-20.04'
macImage: 'macOS-11'
macImage: 'macOS-12'
trigger:
branches:
@@ -166,10 +166,10 @@ stages:
pool:
vmImage: $(imageName)
steps:
- task: NodeTool@0
- task: UseNode@1
displayName: Set Node.js version
inputs:
versionSpec: $(nodeVersion)
version: $(nodeVersion)
- checkout: self
submodules: true
fetchDepth: 1
@@ -1089,10 +1089,10 @@ stages:
pool:
vmImage: $(imageName)
steps:
- task: NodeTool@0
- task: UseNode@1
displayName: Set Node.js version
inputs:
versionSpec: $(nodeVersion)
version: $(nodeVersion)
- checkout: self
submodules: true
fetchDepth: 1

10
docs.sh
View File

@@ -21,15 +21,21 @@ slnFile=src/Radarr.sln
platform=Posix
if [ "$PLATFORM" = "Windows" ]; then
application=Radarr.Console.dll
else
application=Radarr.dll
fi
dotnet clean $slnFile -c Debug
dotnet clean $slnFile -c Release
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
dotnet new tool-manifest
dotnet tool install --version 6.5.0 Swashbuckle.AspNetCore.Cli
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
dotnet tool run swagger tofile --output ./src/Radarr.Api.V3/openapi.json "$outputFolder/net6.0/$RUNTIME/radarr.console.dll" v3 &
dotnet tool run swagger tofile --output ./src/Radarr.Api.V3/openapi.json "$outputFolder/net6.0/$RUNTIME/$application" v3 &
sleep 45

View File

@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
@@ -20,6 +21,7 @@ import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import BlocklistFilterModal from './BlocklistFilterModal';
import BlocklistRowConnector from './BlocklistRowConnector';
class Blocklist extends Component {
@@ -114,9 +116,13 @@ class Blocklist extends Component {
error,
items,
columns,
selectedFilterKey,
filters,
customFilters,
totalRecords,
isRemoving,
isClearingBlocklistExecuting,
onFilterSelect,
...otherProps
} = this.props;
@@ -161,6 +167,15 @@ class Blocklist extends Component {
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={BlocklistFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
@@ -180,7 +195,11 @@ class Blocklist extends Component {
{
isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}>
{translate('NoHistoryBlocklist')}
{
selectedFilterKey === 'all' ?
translate('NoHistoryBlocklist') :
translate('BlocklistFilterHasNoItems')
}
</Alert>
}
@@ -251,11 +270,15 @@ Blocklist.propTypes = {
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isRemoving: PropTypes.bool.isRequired,
isClearingBlocklistExecuting: PropTypes.bool.isRequired,
onRemoveSelected: PropTypes.func.isRequired,
onClearBlocklistPress: PropTypes.func.isRequired
onClearBlocklistPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
export default Blocklist;

View File

@@ -6,6 +6,7 @@ import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage';
import * as blocklistActions from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Blocklist from './Blocklist';
@@ -13,10 +14,12 @@ import Blocklist from './Blocklist';
function createMapStateToProps() {
return createSelector(
(state) => state.blocklist,
createCustomFiltersSelector('blocklist'),
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST),
(blocklist, isClearingBlocklistExecuting) => {
(blocklist, customFilters, isClearingBlocklistExecuting) => {
return {
isClearingBlocklistExecuting,
customFilters,
...blocklist
};
}
@@ -97,6 +100,14 @@ class BlocklistConnector extends Component {
this.props.setBlocklistSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setBlocklistFilter({ selectedFilterKey });
};
onClearBlocklistPress = () => {
this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST });
};
onTableOptionChange = (payload) => {
this.props.setBlocklistTableOption(payload);
@@ -105,10 +116,6 @@ class BlocklistConnector extends Component {
}
};
onClearBlocklistPress = () => {
this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST });
};
//
// Render
@@ -122,6 +129,7 @@ class BlocklistConnector extends Component {
onPageSelect={this.onPageSelect}
onRemoveSelected={this.onRemoveSelected}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onClearBlocklistPress={this.onClearBlocklistPress}
{...this.props}
@@ -142,6 +150,7 @@ BlocklistConnector.propTypes = {
gotoBlocklistPage: PropTypes.func.isRequired,
removeBlocklistItems: PropTypes.func.isRequired,
setBlocklistSort: PropTypes.func.isRequired,
setBlocklistFilter: PropTypes.func.isRequired,
setBlocklistTableOption: PropTypes.func.isRequired,
clearBlocklist: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired

View File

@@ -0,0 +1,54 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
function createBlocklistSelector() {
return createSelector(
(state: AppState) => state.blocklist.items,
(blocklistItems) => {
return blocklistItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.blocklist.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
interface BlocklistFilterModalProps {
isOpen: boolean;
}
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
const sectionItems = useSelector(createBlocklistSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'blocklist';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setBlocklistFilter(payload));
},
[dispatch]
);
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

View File

@@ -219,6 +219,7 @@ class Queue extends Component {
>
<TableOptionsModalWrapper
columns={columns}
maxPageSize={200}
{...otherProps}
optionsComponent={QueueOptionsConnector}
>

View File

@@ -70,6 +70,11 @@ function QueueStatus(props) {
iconName = icons.DOWNLOADED;
title = translate('Downloaded');
if (trackedDownloadState === 'importBlocked') {
title += ` - ${translate('UnableToImportAutomatically')}`;
iconKind = kinds.WARNING;
}
if (trackedDownloadState === 'importPending') {
title += ` - ${translate('WaitingToImport')}`;
iconKind = kinds.PURPLE;

View File

@@ -12,11 +12,10 @@ function App({ store, history }) {
<DocumentTitle title={window.Radarr.instanceName}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme>
<PageConnector>
<AppRoutes app={App} />
</PageConnector>
</ApplyTheme>
<ApplyTheme />
<PageConnector>
<AppRoutes app={App} />
</PageConnector>
</ConnectedRouter>
</Provider>
</DocumentTitle>

View File

@@ -33,6 +33,8 @@ import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import UpdatesConnector from 'System/Updates/UpdatesConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
function AppRoutes(props) {
const {
@@ -121,6 +123,20 @@ function AppRoutes(props) {
component={BlocklistConnector}
/>
{/*
Wanted
*/}
<Route
path="/wanted/missing"
component={MissingConnector}
/>
<Route
path="/wanted/cutoffunmet"
component={CutoffUnmetConnector}
/>
{/*
Settings
*/}

View File

@@ -1,49 +0,0 @@
import PropTypes from 'prop-types';
import React, { Fragment, useCallback, useEffect } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.ui.item.theme || window.Radarr.theme,
(
theme
) => {
return {
theme
};
}
);
}
function ApplyTheme({ theme, children }) {
// Update the CSS Variables
const updateCSSVariables = useCallback(() => {
const arrayOfVariableKeys = Object.keys(themes[theme]);
const arrayOfVariableValues = Object.values(themes[theme]);
// Loop through each array key and set the CSS Variables
arrayOfVariableKeys.forEach((cssVariableKey, index) => {
// Based on our snippet from MDN
document.documentElement.style.setProperty(
`--${cssVariableKey}`,
arrayOfVariableValues[index]
);
});
}, [theme]);
// On Component Mount and Component Update
useEffect(() => {
updateCSSVariables(theme);
}, [updateCSSVariables, theme]);
return <Fragment>{children}</Fragment>;
}
ApplyTheme.propTypes = {
theme: PropTypes.string.isRequired,
children: PropTypes.object.isRequired
};
export default connect(createMapStateToProps)(ApplyTheme);

View File

@@ -0,0 +1,37 @@
import React, { Fragment, ReactNode, useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
import AppState from './State/AppState';
interface ApplyThemeProps {
children: ReactNode;
}
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Radarr.theme,
(theme) => {
return theme;
}
);
}
function ApplyTheme({ children }: ApplyThemeProps) {
const theme = useSelector(createThemeSelector());
const updateCSSVariables = useCallback(() => {
Object.entries(themes[theme]).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--${key}`, value);
});
}, [theme]);
// On Component Mount and Component Update
useEffect(() => {
updateCSSVariables();
}, [updateCSSVariables, theme]);
return <Fragment>{children}</Fragment>;
}
export default ApplyTheme;

View File

@@ -1,4 +1,5 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
import HistoryAppState from './HistoryAppState';
@@ -54,6 +55,7 @@ export interface AppSectionState {
interface AppState {
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;
commands: CommandAppState;
history: HistoryAppState;

View File

@@ -0,0 +1,8 @@
import Blocklist from 'typings/Blocklist';
import AppSectionState, { AppSectionFilterState } from './AppSectionState';
interface BlocklistAppState
extends AppSectionState<Blocklist>,
AppSectionFilterState<Blocklist> {}
export default BlocklistAppState;

View File

@@ -115,3 +115,16 @@ $hoverScale: 1.05;
color: var(--iconButtonHoverLightColor);
}
}
.excluded {
position: absolute;
top: 0;
right: 0;
z-index: 1;
width: 0;
height: 0;
border-width: 0 25px 25px 0;
border-style: solid;
border-color: transparent var(--dangerColor) transparent transparent;
color: var(--white);
}

View File

@@ -6,6 +6,7 @@ interface CssExports {
'content': string;
'controls': string;
'editorSelect': string;
'excluded': string;
'externalLinks': string;
'link': string;
'monitorToggleButton': string;

View File

@@ -5,6 +5,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
import MoviePoster from 'Movie/MoviePoster';
import translate from 'Utilities/String/translate';
import AddNewCollectionMovieModal from './../AddNewCollectionMovieModal';
import styles from './CollectionMovie.css';
@@ -72,6 +73,7 @@ class CollectionMovie extends Component {
isAvailable,
movieFile,
isExistingMovie,
isExcluded,
posterWidth,
posterHeight,
detailedProgressBar,
@@ -107,6 +109,15 @@ class CollectionMovie extends Component {
</div>
}
{
isExcluded ?
<div
className={styles.excluded}
title={translate('Excluded')}
/> :
null
}
<Link
className={styles.link}
style={elementStyle}
@@ -189,6 +200,7 @@ CollectionMovie.propTypes = {
posterHeight: PropTypes.number.isRequired,
detailedProgressBar: PropTypes.bool.isRequired,
isExistingMovie: PropTypes.bool,
isExcluded: PropTypes.bool,
tmdbId: PropTypes.number.isRequired,
imdbId: PropTypes.string,
youTubeTrailerId: PropTypes.string,

View File

@@ -271,26 +271,32 @@ class EnhancedSelectInput extends Component {
this.setState({ isOpen: !this.state.isOpen });
};
onSelect = (value) => {
if (Array.isArray(this.props.value)) {
let newValue = null;
const index = this.props.value.indexOf(value);
onSelect = (newValue) => {
const { name, value, values, onChange } = this.props;
const additionalProperties = values.find((v) => v.key === newValue)?.additionalProperties;
if (Array.isArray(value)) {
let arrayValue = null;
const index = value.indexOf(newValue);
if (index === -1) {
newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v));
arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v));
} else {
newValue = [...this.props.value];
newValue.splice(index, 1);
arrayValue = [...value];
arrayValue.splice(index, 1);
}
this.props.onChange({
name: this.props.name,
value: newValue
onChange({
name,
value: arrayValue,
additionalProperties
});
} else {
this.setState({ isOpen: false });
this.props.onChange({
name: this.props.name,
value
onChange({
name,
value: newValue,
additionalProperties
});
}
};
@@ -485,7 +491,7 @@ class EnhancedSelectInput extends Component {
values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected = hasParent && value.includes(v.parentKey);
const parentSelected = hasParent && Array.isArray(value) && value.includes(v.parentKey);
return (
<OptionComponent
key={v.key}

View File

@@ -9,7 +9,8 @@ import EnhancedSelectInput from './EnhancedSelectInput';
const importantFieldNames = [
'baseUrl',
'apiPath',
'apiKey'
'apiKey',
'authToken'
];
function getProviderDataKey(providerData) {
@@ -34,7 +35,9 @@ function getSelectOptions(items) {
key: option.value,
value: option.name,
hint: option.hint,
parentKey: option.parentValue
parentKey: option.parentValue,
isDisabled: option.isDisabled,
additionalProperties: option.additionalProperties
};
});
}
@@ -147,7 +150,7 @@ EnhancedSelectInputConnector.propTypes = {
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
selectOptionsProviderAction: PropTypes.string,
onChange: PropTypes.func.isRequired,

View File

@@ -1,5 +0,0 @@
.input {
composes: input from '~Components/Form/TextInput.css';
font-family: $passwordFamily;
}

View File

@@ -1,7 +1,5 @@
import PropTypes from 'prop-types';
import React from 'react';
import TextInput from './TextInput';
import styles from './PasswordInput.css';
// Prevent a user from copying (or cutting) the password from the input
function onCopy(e) {
@@ -13,17 +11,14 @@ function PasswordInput(props) {
return (
<TextInput
{...props}
type="password"
onCopy={onCopy}
/>
);
}
PasswordInput.propTypes = {
className: PropTypes.string.isRequired
};
PasswordInput.defaultProps = {
className: styles.input
...TextInput.props
};
export default PasswordInput;

View File

@@ -71,6 +71,22 @@ const links = [
]
},
{
iconName: icons.WARNING,
title: () => translate('Wanted'),
to: '/wanted/missing',
children: [
{
title: () => translate('Missing'),
to: '/wanted/missing'
},
{
title: () => translate('CutoffUnmet'),
to: '/wanted/cutoffunmet'
}
]
},
{
iconName: icons.SETTINGS,
title: () => translate('Settings'),

View File

@@ -244,6 +244,26 @@ class SignalRConnector extends Component {
this.props.dispatchSetVersion({ version });
};
handleWantedCutoff = (body) => {
if (body.action === 'updated') {
this.props.dispatchUpdateItem({
section: 'wanted.cutoffUnmet',
updateOnly: true,
...body.resource
});
}
};
handleWantedMissing = (body) => {
if (body.action === 'updated') {
this.props.dispatchUpdateItem({
section: 'wanted.missing',
updateOnly: true,
...body.resource
});
}
};
handleSystemTask = () => {
this.props.dispatchFetchCommands();
};

View File

@@ -49,11 +49,12 @@ class TableOptionsModal extends Component {
onPageSizeChange = ({ value }) => {
let pageSizeError = null;
const maxPageSize = this.props.maxPageSize ?? 250;
if (value < 5) {
pageSizeError = translate('TablePageSizeMinimum', { minimumValue: '5' });
} else if (value > 250) {
pageSizeError = translate('TablePageSizeMaximum', { maximumValue: '250' });
} else if (value > maxPageSize) {
pageSizeError = translate('TablePageSizeMaximum', { maximumValue: `${maxPageSize}` });
} else {
this.props.onTableOptionChange({ pageSize: value });
}
@@ -248,6 +249,7 @@ TableOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
pageSize: PropTypes.number,
maxPageSize: PropTypes.number,
canModifyColumns: PropTypes.bool.isRequired,
optionsComponent: PropTypes.elementType,
onTableOptionChange: PropTypes.func.isRequired,

View File

@@ -25,14 +25,3 @@
font-family: 'Ubuntu Mono';
src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype');
}
/*
* text-security-disc
*/
@font-face {
font-weight: normal;
font-style: normal;
font-family: 'text-security-disc';
src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') format('truetype');
}

View File

@@ -75,9 +75,19 @@ class DiscoverMovie extends Component {
const {
items,
sortKey,
sortDirection
sortDirection,
includeRecommendations,
includeTrending,
includePopular
} = this.props;
if (includeRecommendations !== prevProps.includeRecommendations ||
includeTrending !== prevProps.includeTrending ||
includePopular !== prevProps.includePopular
) {
this.props.dispatchFetchListMovies();
}
if (sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection ||
hasDifferentItemsOrOrder(prevProps.items, items)
@@ -443,6 +453,9 @@ DiscoverMovie.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
view: PropTypes.string.isRequired,
includeRecommendations: PropTypes.bool.isRequired,
includeTrending: PropTypes.bool.isRequired,
includePopular: PropTypes.bool.isRequired,
isSyncingLists: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired,
@@ -451,7 +464,8 @@ DiscoverMovie.propTypes = {
onScroll: PropTypes.func.isRequired,
onAddMoviesPress: PropTypes.func.isRequired,
onExcludeMoviesPress: PropTypes.func.isRequired,
onImportListSyncPress: PropTypes.func.isRequired
onImportListSyncPress: PropTypes.func.isRequired,
dispatchFetchListMovies: PropTypes.func.isRequired
};
export default DiscoverMovie;

View File

@@ -17,15 +17,18 @@ import DiscoverMovie from './DiscoverMovie';
function createMapStateToProps() {
return createSelector(
(state) => state.discoverMovie,
createDiscoverMovieClientSideCollectionItemsSelector('discoverMovie'),
createCommandExecutingSelector(commandNames.IMPORT_LIST_SYNC),
createDimensionsSelector(),
(
discoverMovie,
movies,
isSyncingLists,
dimensionsState
) => {
return {
...discoverMovie.options,
...movies,
isSyncingLists,
isSmallScreen: dimensionsState.isSmallScreen

View File

@@ -49,7 +49,9 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
showRatings: props.showRatings,
showYear: props.showYear,
showGenres: props.showGenres,
includeRecommendations: props.includeRecommendations
includeRecommendations: props.includeRecommendations,
includeTrending: props.includeTrending,
includePopular: props.includePopular
};
}
@@ -61,7 +63,9 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
showRatings,
showCertification,
showGenres,
includeRecommendations
includeRecommendations,
includeTrending,
includePopular
} = this.props;
const state = {};
@@ -94,6 +98,14 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
state.includeRecommendations = includeRecommendations;
}
if (includeTrending !== prevProps.includeTrending) {
state.includeTrending = includeTrending;
}
if (includePopular !== prevProps.includePopular) {
state.includePopular = includePopular;
}
if (!_.isEmpty(state)) {
this.setState(state);
}
@@ -135,19 +147,22 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
showRatings,
showYear,
showGenres,
includeRecommendations
includeRecommendations,
includeTrending,
includePopular
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Overview Options
{translate('OverviewOptions')}
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('IncludeRadarrRecommendations')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeRecommendations"
@@ -157,6 +172,30 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IncludeTrending')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeTrending"
value={includeTrending}
helpText={translate('IncludeTrendingMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IncludePopular')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includePopular"
value={includePopular}
helpText={translate('IncludePopularMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel>
@@ -246,6 +285,8 @@ DiscoverMovieOverviewOptionsModalContent.propTypes = {
showCertification: PropTypes.bool.isRequired,
showGenres: PropTypes.bool.isRequired,
includeRecommendations: PropTypes.bool.isRequired,
includeTrending: PropTypes.bool.isRequired,
includePopular: PropTypes.bool.isRequired,
onChangeOverviewOption: PropTypes.func.isRequired,
onChangeOption: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired

View File

@@ -3,7 +3,7 @@ import React from 'react';
import Icon from 'Components/Icon';
import TmdbRating from 'Components/TmdbRating';
import { icons } from 'Helpers/Props';
import { getMovieStatusDetails } from 'Movie/MovieStatus';
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import formatRuntime from 'Utilities/Date/formatRuntime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import translate from 'Utilities/String/translate';

View File

@@ -45,7 +45,9 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
this.state = {
size: props.size,
showTitle: props.showTitle,
includeRecommendations: props.includeRecommendations
includeRecommendations: props.includeRecommendations,
includeTrending: props.includeTrending,
includePopular: props.includePopular
};
}
@@ -53,7 +55,9 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
const {
size,
showTitle,
includeRecommendations
includeRecommendations,
includeTrending,
includePopular
} = this.props;
const state = {};
@@ -70,6 +74,14 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
state.includeRecommendations = includeRecommendations;
}
if (includeTrending !== prevProps.includeTrending) {
state.includeTrending = includeTrending;
}
if (includePopular !== prevProps.includePopular) {
state.includePopular = includePopular;
}
if (!_.isEmpty(state)) {
this.setState(state);
}
@@ -107,13 +119,15 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
const {
size,
showTitle,
includeRecommendations
includeRecommendations,
includeTrending,
includePopular
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Poster Options
{translate('PosterOptions')}
</ModalHeader>
<ModalBody>
@@ -130,6 +144,30 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IncludeTrending')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeTrending"
value={includeTrending}
helpText={translate('IncludeTrendingMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IncludePopular')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includePopular"
value={includePopular}
helpText={translate('IncludePopularMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel>
@@ -172,6 +210,8 @@ DiscoverMoviePosterOptionsModalContent.propTypes = {
size: PropTypes.string.isRequired,
showTitle: PropTypes.bool.isRequired,
includeRecommendations: PropTypes.bool.isRequired,
includeTrending: PropTypes.bool.isRequired,
includePopular: PropTypes.bool.isRequired,
onChangePosterOption: PropTypes.func.isRequired,
onChangeOption: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired

View File

@@ -15,17 +15,29 @@ class DiscoverMovieTableOptions extends Component {
super(props, context);
this.state = {
includeRecommendations: props.includeRecommendations
includeRecommendations: props.includeRecommendations,
includeTrending: props.includeTrending,
includePopular: props.includePopular
};
}
componentDidUpdate(prevProps) {
const { includeRecommendations } = this.props;
const {
includeRecommendations,
includeTrending,
includePopular
} = this.props;
if (includeRecommendations !== prevProps.includeRecommendations) {
this.setState({
includeRecommendations
});
this.setState({ includeRecommendations });
}
if (includeTrending !== prevProps.includeTrending) {
this.setState({ includeTrending });
}
if (includePopular !== prevProps.includePopular) {
this.setState({ includePopular });
}
}
@@ -47,27 +59,57 @@ class DiscoverMovieTableOptions extends Component {
render() {
const {
includeRecommendations
includeRecommendations,
includeTrending,
includePopular
} = this.state;
return (
<FormGroup>
<FormLabel>{translate('IncludeRadarrRecommendations')}</FormLabel>
<>
<FormGroup>
<FormLabel>{translate('IncludeRadarrRecommendations')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeRecommendations"
value={includeRecommendations}
helpText={translate('IncludeRecommendationsHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.CHECK}
name="includeRecommendations"
value={includeRecommendations}
helpText={translate('IncludeRecommendationsHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IncludeTrending')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeTrending"
value={includeTrending}
helpText={translate('IncludeTrendingMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IncludePopular')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includePopular"
value={includePopular}
helpText={translate('IncludePopularMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
</>
);
}
}
DiscoverMovieTableOptions.propTypes = {
includeRecommendations: PropTypes.bool.isRequired,
includeTrending: PropTypes.bool.isRequired,
includePopular: PropTypes.bool.isRequired,
onChangeOption: PropTypes.func.isRequired
};

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
import { getMovieStatusDetails } from 'Movie/MovieStatus';
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import styles from './ListMovieStatusCell.css';
function ListMovieStatusCell(props) {

View File

@@ -104,7 +104,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
useEffect(
() => {
if (allowMovieChange && movie && quality && languages) {
if (allowMovieChange && movie && quality && languages && size > 0) {
onSelectedChange({
id,
hasMovieFileId: !!movieFileId,

View File

@@ -6,7 +6,17 @@
margin-right: 8px;
}
.folderPath {
font-weight: bold;
font-family: var(--defaultFontFamily);
}
.deleteFilesMessage {
margin-top: 20px;
color: var(--dangerColor);
.deleteCount {
margin-top: 20px;
color: var(--warningColor);
}
}

View File

@@ -1,7 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'deleteCount': string;
'deleteFilesMessage': string;
'folderPath': string;
'pathContainer': string;
'pathIcon': string;
}

View File

@@ -5,6 +5,7 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
@@ -49,34 +50,26 @@ class DeleteMovieModalContent extends Component {
const {
title,
path,
hasFile,
statistics,
statistics = {},
deleteOptions,
onModalClose,
onDeleteOptionChange
} = this.props;
const {
movieFileCount = 0,
sizeOnDisk = 0
} = statistics;
const deleteFiles = this.state.deleteFiles;
const addImportExclusion = deleteOptions.addImportExclusion;
let deleteFilesLabel = hasFile ? translate('DeleteFileLabel', [1]) : translate('DeleteFilesLabel', [0]);
let deleteFilesHelpText = translate('DeleteFilesHelpText');
if (!hasFile) {
deleteFilesLabel = translate('DeleteMovieFolderLabel');
deleteFilesHelpText = translate('DeleteMovieFolderHelpText');
}
return (
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
{translate('DeleteHeader', [title])}
{translate('DeleteHeader', { title })}
</ModalHeader>
<ModalBody>
@@ -105,32 +98,32 @@ class DeleteMovieModalContent extends Component {
</FormGroup>
<FormGroup>
<FormLabel>{deleteFilesLabel}</FormLabel>
<FormLabel>{movieFileCount === 0 ? translate('DeleteMovieFolder') : translate('DeleteMovieFiles', { movieFileCount })}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={deleteFilesHelpText}
helpText={movieFileCount === 0 ? translate('DeleteMovieFolderHelpText') : translate('DeleteMovieFilesHelpText')}
kind={kinds.DANGER}
onChange={this.onDeleteFilesChange}
/>
</FormGroup>
{
deleteFiles &&
deleteFiles ?
<div className={styles.deleteFilesMessage}>
<div>
{translate('DeleteTheMovieFolder', { path })}
</div>
<div><InlineMarkdown data={translate('DeleteMovieFolderConfirmation', { path })} blockClassName={styles.folderPath} /></div>
{
!!hasFile &&
<div>
{hasFile} {translate('MovieFilesTotaling')} {formatBytes(sizeOnDisk)}
</div>
movieFileCount ?
<div className={styles.deleteCount}>
{translate('DeleteMovieFolderMovieCount', { movieFileCount, size: formatBytes(sizeOnDisk) })}
</div> :
null
}
</div>
</div> :
null
}
</ModalBody>

View File

@@ -1,4 +1,3 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
@@ -51,7 +50,8 @@ const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function getFanartUrl(images) {
return _.find(images, { coverType: 'fanart' })?.url;
const image = images.find((img) => img.coverType === 'fanart');
return image?.url ?? image?.remoteUrl;
}
class MovieDetails extends Component {
@@ -285,8 +285,10 @@ class MovieDetails extends Component {
const fanartUrl = getFanartUrl(images);
const marqueeWidth = isSmallScreen ? titleWidth : (titleWidth - 150);
const pageTitle = `${title}${year > 0 ? ` (${year})` : ''}`;
return (
<PageContent title={title}>
<PageContent title={pageTitle}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton

View File

@@ -8,18 +8,29 @@ import translate from 'Utilities/String/translate';
import styles from './MovieReleaseDates.css';
interface MovieReleaseDatesProps {
inCinemas: string;
physicalRelease: string;
digitalRelease: string;
inCinemas?: string;
digitalRelease?: string;
physicalRelease?: string;
}
function MovieReleaseDates(props: MovieReleaseDatesProps) {
const { inCinemas, physicalRelease, digitalRelease } = props;
const { inCinemas, digitalRelease, physicalRelease } = props;
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
if (!inCinemas && !physicalRelease && !digitalRelease) {
return (
<div>
<div className={styles.dateIcon}>
<Icon name={icons.MISSING} />
</div>
{translate('NoMovieReleaseDatesAvailable')}
</div>
);
}
return (
<div>
{inCinemas ? (

View File

@@ -15,7 +15,7 @@ function MovieHistoryModal(props) {
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
size={sizes.EXTRA_LARGE}
size={sizes.EXTRA_EXTRA_LARGE}
>
<MovieHistoryModalContentConnector
{...otherProps}

View File

@@ -4,13 +4,13 @@ import HistoryDetailsModal from 'Activity/History/Details/HistoryDetailsModal';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import MovieFormats from 'Movie/MovieFormats';
import MovieLanguage from 'Movie/MovieLanguage';
import MovieQuality from 'Movie/MovieQuality';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './MovieHistoryRow.css';
@@ -109,9 +109,9 @@ class MovieHistoryRow extends Component {
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
<RelativeDateCellConnector
date={date}
/>
<TableRowCell>
{formatDateTime(date, shortDateFormat, timeFormat, { includeSeconds: true })}
</TableRowCell>
<TableRowCell className={styles.actions}>
<IconButton

View File

@@ -10,4 +10,15 @@
.path {
margin-left: 5px;
color: var(--dangerColor);
font-weight: bold;
}
.statistics {
margin-left: 5px;
color: var(--warningColor);
}
.deleteFilesMessage {
margin-top: 20px;
color: var(--warningColor);
}

View File

@@ -1,9 +1,11 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'deleteFilesMessage': string;
'message': string;
'path': string;
'pathContainer': string;
'statistics': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -16,6 +16,7 @@ import Movie from 'Movie/Movie';
import { bulkDeleteMovie, setDeleteOption } from 'Store/Actions/movieActions';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import { CheckInputChanged } from 'typings/inputs';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './DeleteMovieModalContent.css';
@@ -85,9 +86,31 @@ function DeleteMovieModalContent(props: DeleteMovieModalContentProps) {
onModalClose,
]);
const { totalMovieFileCount, totalSizeOnDisk } = useMemo(() => {
return movies.reduce(
(acc, m) => {
const { statistics = { movieFileCount: 0, sizeOnDisk: 0 } } = m;
const { movieFileCount = 0, sizeOnDisk = 0 } = statistics;
acc.totalMovieFileCount += movieFileCount;
acc.totalSizeOnDisk += sizeOnDisk;
return acc;
},
{
totalMovieFileCount: 0,
totalSizeOnDisk: 0,
}
);
}, [movies]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('DeleteSelectedMovie')}</ModalHeader>
<ModalHeader>
{movies.length > 1
? translate('DeleteSelectedMovies')
: translate('DeleteSelectedMovie')}
</ModalHeader>
<ModalBody>
<div>
@@ -104,17 +127,21 @@ function DeleteMovieModalContent(props: DeleteMovieModalContentProps) {
</FormGroup>
<FormGroup>
<FormLabel>{`Delete Movie Folder${
movies.length > 1 ? 's' : ''
}`}</FormLabel>
<FormLabel>
{movies.length > 1
? translate('DeleteMovieFolders')
: translate('DeleteMovieFolder')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={`Delete Movie Folder${
movies.length > 1 ? 's' : ''
} and all contents`}
helpText={
movies.length > 1
? translate('DeleteMovieFoldersHelpText')
: translate('DeleteMovieFolderHelpText')
}
kind={kinds.DANGER}
onChange={onDeleteFilesChange}
/>
@@ -122,26 +149,54 @@ function DeleteMovieModalContent(props: DeleteMovieModalContentProps) {
</div>
<div className={styles.message}>
{`Are you sure you want to delete ${movies.length} selected movie(s)${
deleteFiles ? ' and all contents' : ''
}?`}
{deleteFiles
? translate('DeleteMovieFolderCountWithFilesConfirmation', {
count: movies.length,
})
: translate('DeleteMovieFolderCountConfirmation', {
count: movies.length,
})}
</div>
<ul>
{movies.map((s) => {
{movies.map((m) => {
const { movieFileCount = 0, sizeOnDisk = 0 } = m.statistics;
return (
<li key={s.title}>
<span>{s.title}</span>
<li key={m.title}>
<span>{m.title}</span>
{deleteFiles && (
<span className={styles.pathContainer}>
-<span className={styles.path}>{s.path}</span>
<span>
<span className={styles.pathContainer}>
-<span className={styles.path}>{m.path}</span>
</span>
{!!movieFileCount && (
<span className={styles.statistics}>
(
{translate('DeleteMovieFolderMovieCount', {
movieFileCount,
size: formatBytes(sizeOnDisk),
})}
)
</span>
)}
</span>
)}
</li>
);
})}
</ul>
{deleteFiles && !!totalMovieFileCount ? (
<div className={styles.deleteFilesMessage}>
{translate('DeleteMovieFolderMovieCount', {
movieFileCount: totalMovieFileCount,
size: formatBytes(totalSizeOnDisk),
})}
</div>
) : null}
</ModalBody>
<ModalFooter>

View File

@@ -4,7 +4,7 @@ import Icon from 'Components/Icon';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons } from 'Helpers/Props';
import { getMovieStatusDetails } from 'Movie/MovieStatus';
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import { toggleMovieMonitored } from 'Store/Actions/movieActions';
import translate from 'Utilities/String/translate';
import styles from './MovieStatusCell.css';

View File

@@ -0,0 +1,6 @@
.movieSearchCell {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 70px;
white-space: nowrap;
}

View File

@@ -0,0 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'movieSearchCell': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,81 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import MovieInteractiveSearchModalConnector from './Search/MovieInteractiveSearchModalConnector';
import styles from './MovieSearchCell.css';
class MovieSearchCell extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isInteractiveSearchModalOpen: false
};
}
//
// Listeners
onManualSearchPress = () => {
this.setState({ isInteractiveSearchModalOpen: true });
};
onInteractiveSearchModalClose = () => {
this.setState({ isInteractiveSearchModalOpen: false });
};
//
// Render
render() {
const {
movieId,
movieTitle,
isSearching,
onSearchPress,
...otherProps
} = this.props;
return (
<TableRowCell className={styles.movieSearchCell}>
<SpinnerIconButton
name={icons.SEARCH}
isSpinning={isSearching}
onPress={onSearchPress}
title={translate('AutomaticSearch')}
/>
<IconButton
name={icons.INTERACTIVE}
onPress={this.onManualSearchPress}
title={translate('InteractiveSearch')}
/>
<MovieInteractiveSearchModalConnector
isOpen={this.state.isInteractiveSearchModalOpen}
movieId={movieId}
movieTitle={movieTitle}
onModalClose={this.onInteractiveSearchModalClose}
{...otherProps}
/>
</TableRowCell>
);
}
}
MovieSearchCell.propTypes = {
movieId: PropTypes.number.isRequired,
movieTitle: PropTypes.string.isRequired,
isSearching: PropTypes.bool.isRequired,
onSearchPress: PropTypes.func.isRequired
};
export default MovieSearchCell;

View File

@@ -0,0 +1,48 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import MovieSearchCell from 'Movie/MovieSearchCell';
import { executeCommand } from 'Store/Actions/commandActions';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createMovieSelector from 'Store/Selectors/createMovieSelector';
import { isCommandExecuting } from 'Utilities/Command';
function createMapStateToProps() {
return createSelector(
(state, { movieId }) => movieId,
createMovieSelector(),
createCommandsSelector(),
(movieId, movie, commands) => {
const isSearching = commands.some((command) => {
const movieSearch = command.name === commandNames.MOVIE_SEARCH;
if (!movieSearch) {
return false;
}
return (
isCommandExecuting(command) &&
command.body.movieIds.indexOf(movieId) > -1
);
});
return {
movieMonitored: movie.monitored,
isSearching
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSearchPress(name, path) {
dispatch(executeCommand({
name: commandNames.MOVIE_SEARCH,
movieIds: [props.movieId]
}));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(MovieSearchCell);

View File

@@ -0,0 +1,4 @@
.center {
display: flex;
justify-content: center;
}

View File

@@ -1,7 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'input': string;
'center': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,33 +1,115 @@
import { icons } from 'Helpers/Props';
import PropTypes from 'prop-types';
import React from 'react';
import QueueDetails from 'Activity/Queue/QueueDetails';
import Icon from 'Components/Icon';
import ProgressBar from 'Components/ProgressBar';
import { icons, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import MovieQuality from './MovieQuality';
import styles from './MovieStatus.css';
export function getMovieStatusDetails(status) {
function MovieStatus(props) {
const {
isAvailable,
monitored,
grabbed,
queueItem,
movieFile
} = props;
let statusDetails = {
icon: icons.ANNOUNCED,
title: translate('Announced'),
message: translate('AnnouncedMsg')
};
const hasMovieFile = !!movieFile;
const isQueued = !!queueItem;
if (status === 'deleted') {
statusDetails = {
icon: icons.MOVIE_DELETED,
title: translate('Deleted'),
message: translate('DeletedMsg')
};
} else if (status === 'inCinemas') {
statusDetails = {
icon: icons.IN_CINEMAS,
title: translate('InCinemas'),
message: translate('InCinemasMsg')
};
} else if (status === 'released') {
statusDetails = {
icon: icons.MOVIE_FILE,
title: translate('Released'),
message: translate('ReleasedMsg')
};
if (isQueued) {
const {
sizeleft,
size
} = queueItem;
const progress = size ? (100 - sizeleft / size * 100) : 0;
return (
<div className={styles.center}>
<QueueDetails
{...queueItem}
progressBar={
<ProgressBar
progress={progress}
kind={kinds.PURPLE}
size={sizes.MEDIUM}
/>
}
/>
</div>
);
}
return statusDetails;
if (grabbed) {
return (
<div className={styles.center}>
<Icon
name={icons.DOWNLOADING}
title={translate('MovieIsDownloading')}
/>
</div>
);
}
if (hasMovieFile) {
const quality = movieFile.quality;
const isCutoffNotMet = movieFile.qualityCutoffNotMet;
return (
<div className={styles.center}>
<MovieQuality
quality={quality}
size={movieFile.size}
isCutoffNotMet={isCutoffNotMet}
title={translate('MovieDownloaded')}
/>
</div>
);
}
if (!monitored) {
return (
<div className={styles.center}>
<Icon
name={icons.UNMONITORED}
kind={kinds.DISABLED}
title={translate('MovieIsNotMonitored')}
/>
</div>
);
}
if (isAvailable) {
return (
<div className={styles.center}>
<Icon
name={icons.MISSING}
title={translate('MovieMissingFromDisk')}
/>
</div>
);
}
return (
<div className={styles.center}>
<Icon
name={icons.NOT_AIRED}
title={translate('MovieIsNotAvailable')}
/>
</div>
);
}
MovieStatus.propTypes = {
isAvailable: PropTypes.bool.isRequired,
monitored: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
movieFile: PropTypes.object
};
export default MovieStatus;

View File

@@ -0,0 +1,50 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import MovieStatus from 'Movie/MovieStatus';
import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
import { createMovieByEntitySelector } from 'Store/Selectors/createMovieSelector';
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
function createMapStateToProps() {
return createSelector(
createMovieByEntitySelector(),
createQueueItemSelector(),
createMovieFileSelector(),
(movie, queueItem, movieFile) => {
const result = _.pick(movie, [
'isAvailable',
'monitored',
'grabbed'
]);
result.queueItem = queueItem;
result.movieFile = movieFile;
return result;
}
);
}
class MovieStatusConnector extends Component {
//
// Render
render() {
return (
<MovieStatus
{...this.props}
/>
);
}
}
MovieStatusConnector.propTypes = {
movieId: PropTypes.number.isRequired,
movieFileId: PropTypes.number.isRequired
};
export default connect(createMapStateToProps, null)(MovieStatusConnector);

View File

@@ -8,6 +8,7 @@ function MovieInteractiveSearchModal(props) {
const {
isOpen,
movieId,
movieTitle,
onModalClose
} = props;
@@ -20,6 +21,7 @@ function MovieInteractiveSearchModal(props) {
>
<MovieInteractiveSearchModalContent
movieId={movieId}
movieTitle={movieTitle}
onModalClose={onModalClose}
/>
</Modal>
@@ -29,6 +31,7 @@ function MovieInteractiveSearchModal(props) {
MovieInteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
movieId: PropTypes.number.isRequired,
movieTitle: PropTypes.string,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -12,13 +12,17 @@ import translate from 'Utilities/String/translate';
function MovieInteractiveSearchModalContent(props) {
const {
movieId,
movieTitle,
onModalClose
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('InteractiveSearchModalHeader')}
{movieTitle === undefined ?
translate('InteractiveSearchModalHeader') :
translate('InteractiveSearchModalHeaderTitle', { title: movieTitle })
}
</ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}>
@@ -38,6 +42,7 @@ function MovieInteractiveSearchModalContent(props) {
MovieInteractiveSearchModalContent.propTypes = {
movieId: PropTypes.number.isRequired,
movieTitle: PropTypes.string,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -0,0 +1,32 @@
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
export default function getMovieStatusDetails(status) {
let statusDetails = {
icon: icons.ANNOUNCED,
title: translate('Announced'),
message: translate('AnnouncedMsg')
};
if (status === 'deleted') {
statusDetails = {
icon: icons.MOVIE_DELETED,
title: translate('Deleted'),
message: translate('DeletedMsg')
};
} else if (status === 'inCinemas') {
statusDetails = {
icon: icons.IN_CINEMAS,
title: translate('InCinemas'),
message: translate('InCinemasMsg')
};
} else if (status === 'released') {
statusDetails = {
icon: icons.MOVIE_FILE,
title: translate('Released'),
message: translate('ReleasedMsg')
};
}
return statusDetails;
}

View File

@@ -1,9 +1,13 @@
export const CALENDAR = 'calendar';
export const MOVIES = 'movies';
export const INTERACTIVE_IMPORT = 'interactiveImport.movies';
export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet';
export const WANTED_MISSING = 'wanted.missing';
export default {
CALENDAR,
MOVIES,
INTERACTIVE_IMPORT
INTERACTIVE_IMPORT,
WANTED_CUTOFF_UNMET,
WANTED_MISSING
};

View File

@@ -8,7 +8,7 @@ function createMapStateToProps() {
createMovieFileSelector(),
(movieFile) => {
return {
language: movieFile ? movieFile.language : undefined
languages: movieFile ? movieFile.languages : undefined
};
}
);

View File

@@ -72,15 +72,15 @@ const fileNameTokens = [
];
const movieTokens = [
{ token: '{Movie Title}', example: 'Movie\'s Title' },
{ token: '{Movie Title:DE}', example: 'Titel des Films' },
{ token: '{Movie CleanTitle}', example: 'Movies Title' },
{ token: '{Movie TitleThe}', example: 'Movie\'s Title, The' },
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας' },
{ token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας' },
{ token: '{Movie Title}', example: 'Movie\'s Title', footNote: 1 },
{ token: '{Movie Title:DE}', example: 'Titel des Films', footNote: 1 },
{ token: '{Movie CleanTitle}', example: 'Movies Title', footNote: 1 },
{ token: '{Movie TitleThe}', example: 'Movie\'s Title, The', footNote: 1 },
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
{ token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
{ token: '{Movie TitleFirstCharacter}', example: 'M' },
{ token: '{Movie TitleFirstCharacter:DE}', example: 'T' },
{ token: '{Movie Collection}', example: 'The Movie Collection' },
{ token: '{Movie Collection}', example: 'The Movie Collection', footNote: 1 },
{ token: '{Movie Certification}', example: 'R' },
{ token: '{Release Year}', example: '2009' }
];
@@ -112,11 +112,11 @@ const mediaInfoTokens = [
];
const releaseGroupTokens = [
{ token: '{Release Group}', example: 'Rls Grp' }
{ token: '{Release Group}', example: 'Rls Grp', footNote: 1 }
];
const editionTokens = [
{ token: '{Edition Tags}', example: 'IMAX' }
{ token: '{Edition Tags}', example: 'IMAX', footNote: 1 }
];
const customFormatTokens = [
@@ -268,7 +268,7 @@ class NamingModal extends Component {
<FieldSet legend={translate('Movie')}>
<div className={styles.groups}>
{
movieTokens.map(({ token, example }) => {
movieTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
@@ -276,6 +276,7 @@ class NamingModal extends Component {
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
@@ -285,6 +286,11 @@ class NamingModal extends Component {
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MovieFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('MovieID')}>
@@ -365,7 +371,7 @@ class NamingModal extends Component {
<FieldSet legend={translate('ReleaseGroup')}>
<div className={styles.groups}>
{
releaseGroupTokens.map(({ token, example }) => {
releaseGroupTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
@@ -373,6 +379,7 @@ class NamingModal extends Component {
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
@@ -382,12 +389,17 @@ class NamingModal extends Component {
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('Edition')}>
<div className={styles.groups}>
{
editionTokens.map(({ token, example }) => {
editionTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
@@ -395,6 +407,7 @@ class NamingModal extends Component {
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
@@ -404,6 +417,11 @@ class NamingModal extends Component {
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('EditionFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('CustomFormats')}>

View File

@@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import {
saveNotification,
setNotificationFieldValue,
setNotificationFieldValues,
setNotificationValue,
testNotification,
toggleAdvancedSettings
@@ -27,7 +27,7 @@ function createMapStateToProps() {
const mapDispatchToProps = {
setNotificationValue,
setNotificationFieldValue,
setNotificationFieldValues,
saveNotification,
testNotification,
toggleAdvancedSettings
@@ -51,8 +51,8 @@ class EditNotificationModalContentConnector extends Component {
this.props.setNotificationValue({ name, value });
};
onFieldChange = ({ name, value }) => {
this.props.setNotificationFieldValue({ name, value });
onFieldChange = ({ name, value, additionalProperties = {} }) => {
this.props.setNotificationFieldValues({ properties: { ...additionalProperties, [name]: value } });
};
onSavePress = () => {
@@ -91,7 +91,7 @@ EditNotificationModalContentConnector.propTypes = {
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setNotificationValue: PropTypes.func.isRequired,
setNotificationFieldValue: PropTypes.func.isRequired,
setNotificationFieldValues: PropTypes.func.isRequired,
saveNotification: PropTypes.func.isRequired,
testNotification: PropTypes.func.isRequired,
toggleAdvancedSettings: PropTypes.func.isRequired,

View File

@@ -0,0 +1,25 @@
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
function createSetProviderFieldValuesReducer(section) {
return (state, { payload }) => {
if (section === payload.section) {
const { properties } = payload;
const newState = getSectionState(state, section);
newState.pendingChanges = Object.assign({}, newState.pendingChanges);
const fields = Object.assign({}, newState.pendingChanges.fields || {});
Object.keys(properties).forEach((name) => {
fields[name] = properties[name];
});
newState.pendingChanges.fields = fields;
return updateSectionState(state, section, newState);
}
return state;
};
}
export default createSetProviderFieldValuesReducer;

View File

@@ -1,29 +1,29 @@
import createAjaxRequest from 'Utilities/createAjaxRequest';
import updateEpisodes from 'Utilities/Episode/updateEpisodes';
import updateMovies from 'Utilities/Movie/updateMovies';
import getSectionState from 'Utilities/State/getSectionState';
function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) {
function createBatchToggleMovieMonitoredHandler(section, fetchHandler) {
return function(getState, payload, dispatch) {
const {
episodeIds,
movieIds,
monitored
} = payload;
const state = getSectionState(getState(), section, true);
dispatch(updateEpisodes(section, state.items, episodeIds, {
dispatch(updateMovies(section, state.items, movieIds, {
isSaving: true
}));
const promise = createAjaxRequest({
url: '/episode/monitor',
url: '/movie/editor',
method: 'PUT',
data: JSON.stringify({ episodeIds, monitored }),
data: JSON.stringify({ movieIds, monitored }),
dataType: 'json'
}).request;
promise.done(() => {
dispatch(updateEpisodes(section, state.items, episodeIds, {
dispatch(updateMovies(section, state.items, movieIds, {
isSaving: false,
monitored
}));
@@ -32,11 +32,11 @@ function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) {
});
promise.fail(() => {
dispatch(updateEpisodes(section, state.items, episodeIds, {
dispatch(updateMovies(section, state.items, movieIds, {
isSaving: false
}));
});
};
}
export default createBatchToggleEpisodeMonitoredHandler;
export default createBatchToggleMovieMonitoredHandler;

View File

@@ -5,6 +5,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetProviderFieldValuesReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValuesReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
@@ -22,6 +23,7 @@ export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificati
export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema';
export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue';
export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue';
export const SET_NOTIFICATION_FIELD_VALUES = 'settings/notifications/setNotificationFieldValues';
export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification';
export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification';
export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification';
@@ -55,6 +57,13 @@ export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VAL
};
});
export const setNotificationFieldValues = createAction(SET_NOTIFICATION_FIELD_VALUES, (payload) => {
return {
section,
...payload
};
});
//
// Details
@@ -99,6 +108,7 @@ export default {
reducers: {
[SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section),
[SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
[SET_NOTIFICATION_FIELD_VALUES]: createSetProviderFieldValuesReducer(section),
[SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {

View File

@@ -1,6 +1,6 @@
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { sortDirections } from 'Helpers/Props';
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
@@ -79,6 +79,31 @@ export const defaultState = {
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: () => translate('All'),
filters: []
}
],
filterBuilderProps: [
{
name: 'movieIds',
label: () => translate('Movie'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.MOVIE
},
{
name: 'protocols',
label: () => translate('Protocol'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.PROTOCOL
}
]
};
@@ -86,6 +111,7 @@ export const persistState = [
'blocklist.pageSize',
'blocklist.sortKey',
'blocklist.sortDirection',
'blocklist.selectedFilterKey',
'blocklist.columns'
];
@@ -99,6 +125,7 @@ export const GOTO_NEXT_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistNextPage';
export const GOTO_LAST_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistLastPage';
export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage';
export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort';
export const SET_BLOCKLIST_FILTER = 'blocklist/setBlocklistFilter';
export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption';
export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem';
export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems';
@@ -114,6 +141,7 @@ export const gotoBlocklistNextPage = createThunk(GOTO_NEXT_BLOCKLIST_PAGE);
export const gotoBlocklistLastPage = createThunk(GOTO_LAST_BLOCKLIST_PAGE);
export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE);
export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT);
export const setBlocklistFilter = createThunk(SET_BLOCKLIST_FILTER);
export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION);
export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM);
export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS);
@@ -134,7 +162,8 @@ export const actionHandlers = handleThunks({
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLOCKLIST_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLOCKLIST_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLOCKLIST_PAGE,
[serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT
[serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT,
[serverSideCollectionHandlers.FILTER]: SET_BLOCKLIST_FILTER
}),
[REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'),

View File

@@ -42,7 +42,9 @@ export const defaultState = {
view: 'overview',
options: {
includeRecommendations: true
includeRecommendations: true,
includeTrending: true,
includePopular: true
},
defaults: {
@@ -583,10 +585,14 @@ export const actionHandlers = handleThunks({
...otherPayload
} = payload;
const includeRecommendations = getState().discoverMovie.options.includeRecommendations;
const {
includeRecommendations = false,
includeTrending = false,
includePopular = false
} = getState().discoverMovie.options;
const promise = createAjaxRequest({
url: `/importlist/movie?includeRecommendations=${includeRecommendations}`,
url: `/importlist/movie?includeRecommendations=${includeRecommendations}&includeTrending=${includeTrending}&includePopular=${includePopular}`,
data: otherPayload,
traditional: true
}).request;

View File

@@ -28,6 +28,7 @@ import * as rootFolders from './rootFolderActions';
import * as settings from './settingsActions';
import * as system from './systemActions';
import * as tags from './tagActions';
import * as wanted from './wantedActions';
export default [
addMovie,
@@ -59,5 +60,6 @@ export default [
movieCredits,
settings,
system,
tags
tags,
wanted
];

View File

@@ -0,0 +1,334 @@
import { createAction } from 'redux-actions';
import { filterTypes, sortDirections } from 'Helpers/Props';
import createBatchToggleMovieMonitoredHandler from 'Store/Actions/Creators/createBatchToggleMovieMonitoredHandler';
import { createThunk, handleThunks } from 'Store/thunks';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
import translate from 'Utilities/String/translate';
import createHandleActions from './Creators/createHandleActions';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'wanted';
//
// State
export const defaultState = {
missing: {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'movieMetadata.sortTitle',
sortDirection: sortDirections.ASCENDING,
error: null,
items: [],
columns: [
{
name: 'movieMetadata.sortTitle',
label: () => translate('MovieTitle'),
isSortable: true,
isVisible: true
},
{
name: 'movieMetadata.year',
label: () => translate('Year'),
isSortable: true,
isVisible: true
},
{
name: 'movieMetadata.inCinemas',
label: () => translate('InCinemas'),
isSortable: true,
isVisible: false
},
{
name: 'movieMetadata.digitalRelease',
label: () => translate('DigitalRelease'),
isSortable: true,
isVisible: false
},
{
name: 'movieMetadata.physicalRelease',
label: () => translate('PhysicalRelease'),
isSortable: true,
isVisible: false
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'monitored',
filters: [
{
key: 'monitored',
label: () => translate('Monitored'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
}
]
},
{
key: 'unmonitored',
label: () => translate('Unmonitored'),
filters: [
{
key: 'monitored',
value: false,
type: filterTypes.EQUAL
}
]
}
]
},
cutoffUnmet: {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'movieMetadata.sortTitle',
sortDirection: sortDirections.ASCENDING,
items: [],
columns: [
{
name: 'movieMetadata.sortTitle',
label: () => translate('MovieTitle'),
isSortable: true,
isVisible: true
},
{
name: 'movieMetadata.year',
label: () => translate('Year'),
isSortable: true,
isVisible: true
},
{
name: 'movieMetadata.inCinemas',
label: () => translate('InCinemas'),
isSortable: true,
isVisible: false
},
{
name: 'movieMetadata.digitalRelease',
label: () => translate('DigitalRelease'),
isSortable: true,
isVisible: false
},
{
name: 'movieMetadata.physicalRelease',
label: () => translate('PhysicalRelease'),
isSortable: true,
isVisible: false
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'monitored',
filters: [
{
key: 'monitored',
label: () => translate('Monitored'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
}
]
},
{
key: 'unmonitored',
label: () => translate('Unmonitored'),
filters: [
{
key: 'monitored',
value: false,
type: filterTypes.EQUAL
}
]
}
]
}
};
export const persistState = [
'wanted.missing.pageSize',
'wanted.missing.sortKey',
'wanted.missing.sortDirection',
'wanted.missing.selectedFilterKey',
'wanted.missing.columns',
'wanted.cutoffUnmet.pageSize',
'wanted.cutoffUnmet.sortKey',
'wanted.cutoffUnmet.sortDirection',
'wanted.cutoffUnmet.selectedFilterKey',
'wanted.cutoffUnmet.columns'
];
//
// Actions Types
export const FETCH_MISSING = 'wanted/missing/fetchMissing';
export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage';
export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage';
export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage';
export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage';
export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage';
export const SET_MISSING_SORT = 'wanted/missing/setMissingSort';
export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter';
export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption';
export const CLEAR_MISSING = 'wanted/missing/clearMissing';
export const BATCH_TOGGLE_MISSING_MOVIES = 'wanted/missing/batchToggleMissingMovies';
export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet';
export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage';
export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage';
export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage';
export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage';
export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage';
export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort';
export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter';
export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption';
export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet';
export const BATCH_TOGGLE_CUTOFF_UNMET_MOVIES = 'wanted/cutoffUnmet/batchToggleCutoffUnmetMovies';
//
// Action Creators
export const fetchMissing = createThunk(FETCH_MISSING);
export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE);
export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE);
export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE);
export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE);
export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE);
export const setMissingSort = createThunk(SET_MISSING_SORT);
export const setMissingFilter = createThunk(SET_MISSING_FILTER);
export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION);
export const clearMissing = createAction(CLEAR_MISSING);
export const batchToggleMissingMovies = createThunk(BATCH_TOGGLE_MISSING_MOVIES);
export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET);
export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE);
export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT);
export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER);
export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION);
export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET);
export const batchToggleCutoffUnmetMovies = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_MOVIES);
//
// Action Handlers
export const actionHandlers = handleThunks({
...createServerSideCollectionHandlers(
'wanted.missing',
'/wanted/missing',
fetchMissing,
{
[serverSideCollectionHandlers.FETCH]: FETCH_MISSING,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE,
[serverSideCollectionHandlers.SORT]: SET_MISSING_SORT,
[serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER
}
),
[BATCH_TOGGLE_MISSING_MOVIES]: createBatchToggleMovieMonitoredHandler('wanted.missing', fetchMissing),
...createServerSideCollectionHandlers(
'wanted.cutoffUnmet',
'/wanted/cutoff',
fetchCutoffUnmet,
{
[serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT,
[serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER
}
),
[BATCH_TOGGLE_CUTOFF_UNMET_MOVIES]: createBatchToggleMovieMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet)
});
//
// Reducers
export const reducers = createHandleActions({
[SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'),
[SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'),
[CLEAR_MISSING]: createClearReducer(
'wanted.missing',
{
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
}
),
[CLEAR_CUTOFF_UNMET]: createClearReducer(
'wanted.cutoffUnmet',
{
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
}
)
}, defaultState, section);

View File

@@ -1,4 +1,6 @@
import _ from 'lodash';
import { createSelector } from 'reselect';
import movieEntities from 'Movie/movieEntities';
export function createMovieSelectorForHook(movieId) {
return createSelector(
@@ -11,6 +13,16 @@ export function createMovieSelectorForHook(movieId) {
);
}
export function createMovieByEntitySelector() {
return createSelector(
(state, { movieId }) => movieId,
(state, { movieEntity = movieEntities.MOVIES }) => _.get(state, movieEntity, { items: [] }),
(movieId, movies) => {
return _.find(movies.items, { id: movieId });
}
);
}
function createMovieSelector() {
return createSelector(
(state, { movieId }) => movieId,

View File

@@ -2,7 +2,7 @@ import * as dark from './dark';
import * as light from './light';
const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const auto = defaultDark ? { ...dark } : { ...light };
const auto = defaultDark ? dark : light;
export default {
auto,

View File

@@ -2,7 +2,6 @@ module.exports = {
// Families
defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;',
passwordFamily: 'text-security-disc',
// Sizes
extraSmallFontSize: '11px',

View File

@@ -6,6 +6,22 @@ import createMultiMoviesSelector from 'Store/Selectors/createMultiMoviesSelector
import translate from 'Utilities/String/translate';
import styles from './QueuedTaskRowNameCell.css';
function formatTitles(titles: string[]) {
if (!titles) {
return null;
}
if (titles.length > 11) {
return (
<span title={titles.join(', ')}>
{titles.slice(0, 10).join(', ')}, {titles.length - 10} more
</span>
);
}
return <span>{titles.join(', ')}</span>;
}
export interface QueuedTaskRowNameCellProps {
commandName: string;
body: CommandBody;
@@ -32,7 +48,7 @@ export default function QueuedTaskRowNameCell(
<span className={styles.commandName}>
{commandName}
{sortedMovies.length ? (
<span> - {sortedMovies.map((m) => m.title).join(', ')}</span>
<span> - {formatTitles(sortedMovies.map((m) => m.title))}</span>
) : null}
</span>

View File

@@ -1,9 +1,9 @@
import _ from 'lodash';
import { update } from 'Store/Actions/baseActions';
function updateEpisodes(section, episodes, episodeIds, options) {
const data = _.reduce(episodes, (result, item) => {
if (episodeIds.indexOf(item.id) > -1) {
function updateMovies(section, movies, movieIds, options) {
const data = _.reduce(movies, (result, item) => {
if (movieIds.indexOf(item.id) > -1) {
result.push({
...item,
...options
@@ -18,4 +18,4 @@ function updateEpisodes(section, episodes, episodeIds, options) {
return update({ section, data });
}
export default updateEpisodes;
export default updateMovies;

View File

@@ -0,0 +1,301 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props';
import getFilterValue from 'Utilities/Filter/getFilterValue';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import CutoffUnmetRow from './CutoffUnmetRow';
function getMonitoredValue(props) {
const {
filters,
selectedFilterKey
} = props;
return getFilterValue(filters, selectedFilterKey, 'monitored', false);
}
class CutoffUnmet extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
isConfirmSearchAllCutoffUnmetModalOpen: false,
isInteractiveImportModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
this.setState((state) => {
return removeOldSelectedState(state, prevProps.items);
});
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
onFilterMenuItemPress = (filterKey, filterValue) => {
this.props.onFilterSelect(filterKey, filterValue);
};
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
};
onSearchSelectedPress = () => {
const selected = this.getSelectedIds();
this.props.onSearchSelectedPress(selected);
};
onToggleSelectedPress = () => {
const movieIds = this.getSelectedIds();
this.props.batchToggleCutoffUnmetMovies({
movieIds,
monitored: !getMonitoredValue(this.props)
});
};
onSearchAllCutoffUnmetPress = () => {
this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true });
};
onSearchAllCutoffUnmetConfirmed = () => {
const {
selectedFilterKey,
onSearchAllCutoffUnmetPress
} = this.props;
// TODO: Custom filters will need to check whether there is a monitored
// filter once implemented.
onSearchAllCutoffUnmetPress(selectedFilterKey === 'monitored');
this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
};
onConfirmSearchAllCutoffUnmetModalClose = () => {
this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
selectedFilterKey,
filters,
columns,
totalRecords,
isSearchingForCutoffUnmetMovies,
isSaving,
onFilterSelect,
...otherProps
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
isConfirmSearchAllCutoffUnmetModalOpen
} = this.state;
const itemsSelected = !!this.getSelectedIds().length;
const isShowingMonitored = getMonitoredValue(this.props);
return (
<PageContent title={translate('CutoffUnmet')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('SearchSelected')}
iconName={icons.SEARCH}
isDisabled={!itemsSelected || isSearchingForCutoffUnmetMovies}
onPress={this.onSearchSelectedPress}
/>
<PageToolbarButton
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
iconName={icons.MONITORED}
isDisabled={!itemsSelected}
isSpinning={isSaving}
onPress={this.onToggleSelectedPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!items.length}
isSpinning={isSearchingForCutoffUnmetMovies}
onPress={this.onSearchAllCutoffUnmetPress}
/>
<PageToolbarSeparator />
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={[]}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && error &&
<Alert kind={kinds.DANGER}>
{translate('CutoffUnmetLoadError')}
</Alert>
}
{
isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}>
{translate('CutoffUnmetNoItems')}
</Alert>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<CutoffUnmetRow
key={item.id}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
<ConfirmModal
isOpen={isConfirmSearchAllCutoffUnmetModalOpen}
kind={kinds.DANGER}
title={translate('SearchForCutoffUnmetMovies')}
message={
<div>
<div>
{translate('SearchForCutoffUnmetMoviesConfirmationCount', { totalRecords })}
</div>
<div>
{translate('MassSearchCancelWarning')}
</div>
</div>
}
confirmLabel={translate('Search')}
onConfirm={this.onSearchAllCutoffUnmetConfirmed}
onCancel={this.onConfirmSearchAllCutoffUnmetModalClose}
/>
</div>
}
</PageContentBody>
</PageContent>
);
}
}
CutoffUnmet.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isSearchingForCutoffUnmetMovies: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onSearchSelectedPress: PropTypes.func.isRequired,
batchToggleCutoffUnmetMovies: PropTypes.func.isRequired,
onSearchAllCutoffUnmetPress: PropTypes.func.isRequired
};
export default CutoffUnmet;

View File

@@ -0,0 +1,185 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import * as wantedActions from 'Store/Actions/wantedActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import CutoffUnmet from './CutoffUnmet';
function createMapStateToProps() {
return createSelector(
(state) => state.wanted.cutoffUnmet,
createCommandExecutingSelector(commandNames.CUTOFF_UNMET_MOVIES_SEARCH),
(cutoffUnmet, isSearchingForCutoffUnmetMovies) => {
return {
isSearchingForCutoffUnmetMovies,
isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1,
...cutoffUnmet
};
}
);
}
const mapDispatchToProps = {
...wantedActions,
executeCommand,
fetchQueueDetails,
clearQueueDetails,
fetchMovieFiles,
clearMovieFiles
};
class CutoffUnmetConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchCutoffUnmet,
gotoCutoffUnmetFirstPage
} = this.props;
registerPagePopulator(this.repopulate, ['movieFileUpdated', 'movieFileDeleted']);
if (useCurrentPage) {
fetchCutoffUnmet();
} else {
gotoCutoffUnmetFirstPage();
}
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const movieIds = selectUniqueIds(this.props.items, 'id');
const movieFileIds = selectUniqueIds(this.props.items, 'movieFileId');
this.props.fetchQueueDetails({ movieIds });
if (movieFileIds.length) {
this.props.fetchMovieFiles({ movieFileIds });
}
}
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
this.props.clearCutoffUnmet();
this.props.clearQueueDetails();
this.props.clearMovieFiles();
}
//
// Control
repopulate = () => {
this.props.fetchCutoffUnmet();
};
//
// Listeners
onFirstPagePress = () => {
this.props.gotoCutoffUnmetFirstPage();
};
onPreviousPagePress = () => {
this.props.gotoCutoffUnmetPreviousPage();
};
onNextPagePress = () => {
this.props.gotoCutoffUnmetNextPage();
};
onLastPagePress = () => {
this.props.gotoCutoffUnmetLastPage();
};
onPageSelect = (page) => {
this.props.gotoCutoffUnmetPage({ page });
};
onSortPress = (sortKey) => {
this.props.setCutoffUnmetSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setCutoffUnmetFilter({ selectedFilterKey });
};
onTableOptionChange = (payload) => {
this.props.setCutoffUnmetTableOption(payload);
if (payload.pageSize) {
this.props.gotoCutoffUnmetFirstPage();
}
};
onSearchSelectedPress = (selected) => {
this.props.executeCommand({
name: commandNames.MOVIE_SEARCH,
movieIds: selected
});
};
onSearchAllCutoffUnmetPress = (monitored) => {
this.props.executeCommand({
name: commandNames.CUTOFF_UNMET_MOVIES_SEARCH,
monitored
});
};
//
// Render
render() {
return (
<CutoffUnmet
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onSearchSelectedPress={this.onSearchSelectedPress}
onToggleSelectedPress={this.onToggleSelectedPress}
onSearchAllCutoffUnmetPress={this.onSearchAllCutoffUnmetPress}
{...this.props}
/>
);
}
}
CutoffUnmetConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchCutoffUnmet: PropTypes.func.isRequired,
gotoCutoffUnmetFirstPage: PropTypes.func.isRequired,
gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired,
gotoCutoffUnmetNextPage: PropTypes.func.isRequired,
gotoCutoffUnmetLastPage: PropTypes.func.isRequired,
gotoCutoffUnmetPage: PropTypes.func.isRequired,
setCutoffUnmetSort: PropTypes.func.isRequired,
setCutoffUnmetFilter: PropTypes.func.isRequired,
setCutoffUnmetTableOption: PropTypes.func.isRequired,
clearCutoffUnmet: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired,
fetchQueueDetails: PropTypes.func.isRequired,
clearQueueDetails: PropTypes.func.isRequired,
fetchMovieFiles: PropTypes.func.isRequired,
clearMovieFiles: PropTypes.func.isRequired
};
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector)
);

View File

@@ -0,0 +1,6 @@
.languages,
.status {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px;
}

View File

@@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'languages': string;
'status': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,157 @@
import PropTypes from 'prop-types';
import React from 'react';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import movieEntities from 'Movie/movieEntities';
import MovieSearchCellConnector from 'Movie/MovieSearchCellConnector';
import MovieStatusConnector from 'Movie/MovieStatusConnector';
import MovieTitleLink from 'Movie/MovieTitleLink';
import MovieFileLanguageConnector from 'MovieFile/MovieFileLanguageConnector';
import styles from './CutoffUnmetRow.css';
function CutoffUnmetRow(props) {
const {
id,
movieFileId,
year,
title,
titleSlug,
inCinemas,
digitalRelease,
physicalRelease,
isSelected,
columns,
onSelectedChange
} = props;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'movieMetadata.sortTitle') {
return (
<TableRowCell key={name}>
<MovieTitleLink
titleSlug={titleSlug}
title={title}
/>
</TableRowCell>
);
}
if (name === 'movieMetadata.year') {
return (
<TableRowCell key={name}>
{year}
</TableRowCell>
);
}
if (name === 'movieMetadata.inCinemas') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={inCinemas}
/>
);
}
if (name === 'movieMetadata.digitalRelease') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={digitalRelease}
/>
);
}
if (name === 'movieMetadata.physicalRelease') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={physicalRelease}
/>
);
}
if (name === 'languages') {
return (
<TableRowCell
key={name}
className={styles.languages}
>
<MovieFileLanguageConnector
movieFileId={movieFileId}
/>
</TableRowCell>
);
}
if (name === 'status') {
return (
<TableRowCell
key={name}
className={styles.status}
>
<MovieStatusConnector
movieId={id}
movieFileId={movieFileId}
movieEntity={movieEntities.WANTED_CUTOFF_UNMET}
/>
</TableRowCell>
);
}
if (name === 'actions') {
return (
<MovieSearchCellConnector
key={name}
movieId={id}
movieTitle={title}
movieEntity={movieEntities.WANTED_CUTOFF_UNMET}
/>
);
}
return null;
})
}
</TableRow>
);
}
CutoffUnmetRow.propTypes = {
id: PropTypes.number.isRequired,
movieFileId: PropTypes.number,
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
inCinemas: PropTypes.string,
digitalRelease: PropTypes.string,
physicalRelease: PropTypes.string,
isSelected: PropTypes.bool,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelectedChange: PropTypes.func.isRequired
};
export default CutoffUnmetRow;

View File

@@ -0,0 +1,319 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import getFilterValue from 'Utilities/Filter/getFilterValue';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import MissingRow from './MissingRow';
function getMonitoredValue(props) {
const {
filters,
selectedFilterKey
} = props;
return getFilterValue(filters, selectedFilterKey, 'monitored', false);
}
class Missing extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
isConfirmSearchAllMissingModalOpen: false,
isInteractiveImportModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
this.setState((state) => {
return removeOldSelectedState(state, prevProps.items);
});
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
};
onSearchSelectedPress = () => {
const selected = this.getSelectedIds();
this.props.onSearchSelectedPress(selected);
};
onToggleSelectedPress = () => {
const movieIds = this.getSelectedIds();
this.props.batchToggleMissingMovies({
movieIds,
monitored: !getMonitoredValue(this.props)
});
};
onSearchAllMissingPress = () => {
this.setState({ isConfirmSearchAllMissingModalOpen: true });
};
onSearchAllMissingConfirmed = () => {
const {
selectedFilterKey,
onSearchAllMissingPress
} = this.props;
// TODO: Custom filters will need to check whether there is a monitored
// filter once implemented.
onSearchAllMissingPress(selectedFilterKey === 'monitored');
this.setState({ isConfirmSearchAllMissingModalOpen: false });
};
onConfirmSearchAllMissingModalClose = () => {
this.setState({ isConfirmSearchAllMissingModalOpen: false });
};
onInteractiveImportPress = () => {
this.setState({ isInteractiveImportModalOpen: true });
};
onInteractiveImportModalClose = () => {
this.setState({ isInteractiveImportModalOpen: false });
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
selectedFilterKey,
filters,
columns,
totalRecords,
isSearchingForMissingMovies,
isSaving,
onFilterSelect,
...otherProps
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
isConfirmSearchAllMissingModalOpen,
isInteractiveImportModalOpen
} = this.state;
const itemsSelected = !!this.getSelectedIds().length;
const isShowingMonitored = getMonitoredValue(this.props);
return (
<PageContent title={translate('Missing')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('SearchSelected')}
iconName={icons.SEARCH}
isDisabled={!itemsSelected || isSearchingForMissingMovies}
onPress={this.onSearchSelectedPress}
/>
<PageToolbarButton
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
iconName={icons.MONITORED}
isDisabled={!itemsSelected}
isSpinning={isSaving}
onPress={this.onToggleSelectedPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!items.length}
isSpinning={isSearchingForMissingMovies}
onPress={this.onSearchAllMissingPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('ManualImport')}
iconName={icons.INTERACTIVE}
onPress={this.onInteractiveImportPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={[]}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && error &&
<Alert kind={kinds.DANGER}>
{translate('MissingLoadError')}
</Alert>
}
{
isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}>
{translate('MissingNoItems')}
</Alert>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<MissingRow
key={item.id}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
<ConfirmModal
isOpen={isConfirmSearchAllMissingModalOpen}
kind={kinds.DANGER}
title={translate('SearchForAllMissingMovies')}
message={
<div>
<div>
{translate('SearchForAllMissingMoviesConfirmationCount', { totalRecords })}
</div>
<div>
{translate('MassSearchCancelWarning')}
</div>
</div>
}
confirmLabel={translate('Search')}
onConfirm={this.onSearchAllMissingConfirmed}
onCancel={this.onConfirmSearchAllMissingModalClose}
/>
</div>
}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
onModalClose={this.onInteractiveImportModalClose}
/>
</PageContentBody>
</PageContent>
);
}
}
Missing.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isSearchingForMissingMovies: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onSearchSelectedPress: PropTypes.func.isRequired,
batchToggleMissingMovies: PropTypes.func.isRequired,
onSearchAllMissingPress: PropTypes.func.isRequired
};
export default Missing;

View File

@@ -0,0 +1,173 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import * as wantedActions from 'Store/Actions/wantedActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Missing from './Missing';
function createMapStateToProps() {
return createSelector(
(state) => state.wanted.missing,
createCommandExecutingSelector(commandNames.MISSING_MOVIES_SEARCH),
(missing, isSearchingForMissingMovies) => {
return {
isSearchingForMissingMovies,
isSaving: missing.items.filter((m) => m.isSaving).length > 1,
...missing
};
}
);
}
const mapDispatchToProps = {
...wantedActions,
executeCommand,
fetchQueueDetails,
clearQueueDetails
};
class MissingConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchMissing,
gotoMissingFirstPage
} = this.props;
registerPagePopulator(this.repopulate, ['movieFileUpdated', 'movieFileDeleted']);
if (useCurrentPage) {
fetchMissing();
} else {
gotoMissingFirstPage();
}
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const movieIds = selectUniqueIds(this.props.items, 'id');
this.props.fetchQueueDetails({ movieIds });
}
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
this.props.clearMissing();
this.props.clearQueueDetails();
}
//
// Control
repopulate = () => {
this.props.fetchMissing();
};
//
// Listeners
onFirstPagePress = () => {
this.props.gotoMissingFirstPage();
};
onPreviousPagePress = () => {
this.props.gotoMissingPreviousPage();
};
onNextPagePress = () => {
this.props.gotoMissingNextPage();
};
onLastPagePress = () => {
this.props.gotoMissingLastPage();
};
onPageSelect = (page) => {
this.props.gotoMissingPage({ page });
};
onSortPress = (sortKey) => {
this.props.setMissingSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setMissingFilter({ selectedFilterKey });
};
onTableOptionChange = (payload) => {
this.props.setMissingTableOption(payload);
if (payload.pageSize) {
this.props.gotoMissingFirstPage();
}
};
onSearchSelectedPress = (selected) => {
this.props.executeCommand({
name: commandNames.MOVIE_SEARCH,
movieIds: selected
});
};
onSearchAllMissingPress = (monitored) => {
this.props.executeCommand({
name: commandNames.MISSING_MOVIES_SEARCH,
monitored
});
};
//
// Render
render() {
return (
<Missing
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onSearchSelectedPress={this.onSearchSelectedPress}
onSearchAllMissingPress={this.onSearchAllMissingPress}
{...this.props}
/>
);
}
}
MissingConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchMissing: PropTypes.func.isRequired,
gotoMissingFirstPage: PropTypes.func.isRequired,
gotoMissingPreviousPage: PropTypes.func.isRequired,
gotoMissingNextPage: PropTypes.func.isRequired,
gotoMissingLastPage: PropTypes.func.isRequired,
gotoMissingPage: PropTypes.func.isRequired,
setMissingSort: PropTypes.func.isRequired,
setMissingFilter: PropTypes.func.isRequired,
setMissingTableOption: PropTypes.func.isRequired,
clearMissing: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired,
fetchQueueDetails: PropTypes.func.isRequired,
clearQueueDetails: PropTypes.func.isRequired
};
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(MissingConnector)
);

View File

@@ -0,0 +1,5 @@
.status {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px;
}

View File

@@ -0,0 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'status': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,147 @@
import PropTypes from 'prop-types';
import React from 'react';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import movieEntities from 'Movie/movieEntities';
import MovieSearchCellConnector from 'Movie/MovieSearchCellConnector';
import MovieStatusConnector from 'Movie/MovieStatusConnector';
import MovieTitleLink from 'Movie/MovieTitleLink';
import styles from './MissingRow.css';
function MissingRow(props) {
const {
id,
movieFileId,
year,
title,
titleSlug,
inCinemas,
digitalRelease,
physicalRelease,
isSelected,
columns,
onSelectedChange
} = props;
if (!title) {
return null;
}
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'movieMetadata.sortTitle') {
return (
<TableRowCell key={name}>
<MovieTitleLink
titleSlug={titleSlug}
title={title}
/>
</TableRowCell>
);
}
if (name === 'movieMetadata.year') {
return (
<TableRowCell key={name}>
{year}
</TableRowCell>
);
}
if (name === 'movieMetadata.inCinemas') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={inCinemas}
/>
);
}
if (name === 'movieMetadata.digitalRelease') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={digitalRelease}
/>
);
}
if (name === 'movieMetadata.physicalRelease') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={physicalRelease}
/>
);
}
if (name === 'status') {
return (
<TableRowCell
key={name}
className={styles.status}
>
<MovieStatusConnector
movieId={id}
movieFileId={movieFileId}
movieEntity={movieEntities.WANTED_MISSING}
/>
</TableRowCell>
);
}
if (name === 'actions') {
return (
<MovieSearchCellConnector
key={name}
movieId={id}
movieTitle={title}
movieEntity={movieEntities.WANTED_MISSING}
/>
);
}
return null;
})
}
</TableRow>
);
}
MissingRow.propTypes = {
id: PropTypes.number.isRequired,
movieFileId: PropTypes.number,
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
inCinemas: PropTypes.string,
digitalRelease: PropTypes.string,
physicalRelease: PropTypes.string,
isSelected: PropTypes.bool,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelectedChange: PropTypes.func.isRequired
};
export default MissingRow;

View File

@@ -57,8 +57,8 @@
<style>
body {
background-color: #f5f7fa;
color: #656565;
background-color: var(--pageBackground);
color: var(--textColor);
font-family: "Roboto", "open sans", "Helvetica Neue", Helvetica, Arial,
sans-serif;
}
@@ -88,14 +88,14 @@
padding: 10px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
background-color: #464b51;
background-color: var(--themeDarkColor);
}
.panel-body {
padding: 20px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
background-color: #fff;
background-color: var(--panelBackground);
}
.sign-in {
@@ -112,16 +112,18 @@
padding: 6px 16px;
width: 100%;
height: 35px;
border: 1px solid #dde6e9;
background-color: var(--inputBackgroundColor);
border: 1px solid var(--inputBorderColor);
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor);
color: var(--textColor);
}
.form-input:focus {
outline: 0;
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075),
0 0 8px rgba(102, 175, 233, 0.6);
border-color: var(--inputFocusBorderColor);
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor),
0 0 8px var(--inputFocusBoxShadowColor);
}
.button {
@@ -130,10 +132,10 @@
padding: 10px 0;
width: 100%;
border: 1px solid;
border-color: #5899eb;
border-color: var(--primaryBorderColor);
border-radius: 4px;
background-color: #5d9cec;
color: #fff;
background-color: var(--primaryBackgroundColor);
color: var(--white);
vertical-align: middle;
text-align: center;
white-space: nowrap;
@@ -141,9 +143,9 @@
}
.button:hover {
border-color: #3483e7;
background-color: #4b91ea;
color: #fff;
border-color: var(--primaryHoverBorderColor);
background-color: var(--primaryHoverBackgroundColor);
color: var(--white);
text-decoration: none;
}
@@ -165,24 +167,24 @@
.forgot-password {
margin-left: auto;
color: #909fa7;
color: var(--forgotPasswordColor);
text-decoration: none;
font-size: 13px;
}
.forgot-password:focus,
.forgot-password:hover {
color: #748690;
color: var(--forgotPasswordAltColor);
text-decoration: underline;
}
.forgot-password:visited {
color: #748690;
color: var(--forgotPasswordAltColor);
}
.login-failed {
margin-top: 20px;
color: #f05050;
color: var(--failedColor);
font-size: 14px;
}
@@ -291,5 +293,59 @@
loginFailedDiv.classList.remove("hidden");
}
var light = {
white: '#fff',
pageBackground: '#f5f7fa',
textColor: '#515253',
themeDarkColor: '#464b51',
panelBackground: '#fff',
inputBackgroundColor: '#fff',
inputBorderColor: '#dde6e9',
inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
inputFocusBorderColor: '#66afe9',
inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
primaryBackgroundColor: '#5d9cec',
primaryBorderColor: '#5899eb',
primaryHoverBackgroundColor: '#4b91ea',
primaryHoverBorderColor: '#3483e7',
failedColor: '#f05050',
forgotPasswordColor: '#909fa7',
forgotPasswordAltColor: '#748690'
};
var dark = {
white: '#fff',
pageBackground: '#202020',
textColor: '#ccc',
themeDarkColor: '#494949',
panelBackground: '#111',
inputBackgroundColor: '#333',
inputBorderColor: '#dde6e9',
inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
inputFocusBorderColor: '#66afe9',
inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
primaryBackgroundColor: '#5d9cec',
primaryBorderColor: '#5899eb',
primaryHoverBackgroundColor: '#4b91ea',
primaryHoverBorderColor: '#3483e7',
failedColor: '#f05050',
forgotPasswordColor: '#737d83',
forgotPasswordAltColor: '#546067'
};
var theme = "_THEME_";
var defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var finalTheme = theme === 'dark' || (theme === 'auto' && defaultDark) ?
dark :
light;
Object.entries(finalTheme).forEach(([key, value]) => {
document.documentElement.style.setProperty(
`--${key}`,
value
);
});
</script>
</html>

View File

@@ -0,0 +1,16 @@
import ModelBase from 'App/ModelBase';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
interface Blocklist extends ModelBase {
languages: Language[];
quality: QualityModel;
customFormats: CustomFormat[];
title: string;
date?: string;
protocol: string;
movieId?: number;
}
export default Blocklist;

View File

@@ -1,5 +1,5 @@
export interface UiSettings {
theme: string;
theme: 'auto' | 'dark' | 'light';
showRelativeDates: boolean;
shortDateFormat: string;
longDateFormat: string;

View File

@@ -29,11 +29,11 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@juggle/resize-observer": "3.4.0",
"@microsoft/signalr": "6.0.25",
"@sentry/browser": "7.51.2",
"@sentry/integrations": "7.51.2",
"@types/node": "18.16.8",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4",
"@sentry/browser": "7.100.0",
"@sentry/integrations": "7.100.0",
"@types/node": "18.19.31",
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25",
"classnames": "2.3.2",
"clipboard": "2.0.11",
"connected-react-router": "6.9.3",
@@ -84,16 +84,16 @@
"reselect": "4.1.8",
"stacktrace-js": "2.0.2",
"swiper": "8.3.2",
"typescript": "4.9.5"
"typescript": "5.1.6"
},
"devDependencies": {
"@babel/core": "7.22.11",
"@babel/eslint-parser": "7.22.11",
"@babel/plugin-proposal-export-default-from": "7.22.5",
"@babel/core": "7.24.4",
"@babel/eslint-parser": "7.24.1",
"@babel/plugin-proposal-export-default-from": "7.24.1",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.22.14",
"@babel/preset-react": "7.22.5",
"@babel/preset-typescript": "7.22.11",
"@babel/preset-env": "7.24.4",
"@babel/preset-react": "7.24.1",
"@babel/preset-typescript": "7.24.1",
"@types/lodash": "4.14.195",
"@types/react-lazyload": "3.2.0",
"@types/react-router-dom": "5.3.3",
@@ -101,31 +101,31 @@
"@types/react-window": "1.8.5",
"@types/redux-actions": "2.6.2",
"@types/webpack-livereload-plugin": "2.3.3",
"@typescript-eslint/eslint-plugin": "5.59.5",
"@typescript-eslint/parser": "5.59.5",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"autoprefixer": "10.4.14",
"babel-loader": "9.1.3",
"babel-plugin-inline-classnames": "2.0.1",
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
"core-js": "3.32.1",
"core-js": "3.37.0",
"css-loader": "6.7.3",
"css-modules-typescript-loader": "4.0.1",
"eslint": "8.45.0",
"eslint-config-prettier": "8.8.0",
"eslint": "8.57.0",
"eslint-config-prettier": "8.10.0",
"eslint-plugin-filenames": "1.3.2",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-json": "3.1.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react": "7.34.1",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-simple-import-sort": "10.0.0",
"eslint-plugin-simple-import-sort": "12.1.0",
"file-loader": "6.2.0",
"filemanager-webpack-plugin": "8.0.0",
"fork-ts-checker-webpack-plugin": "8.0.0",
"html-webpack-plugin": "5.5.3",
"loader-utils": "^3.2.1",
"mini-css-extract-plugin": "2.7.6",
"postcss": "8.4.23",
"postcss": "8.4.38",
"postcss-color-function": "4.1.0",
"postcss-loader": "7.3.0",
"postcss-mixins": "9.0.4",

View File

@@ -99,10 +99,39 @@
<RootNamespace Condition="'$(RadarrProject)'=='true'">$(MSBuildProjectName.Replace('Radarr','NzbDrone'))</RootNamespace>
</PropertyGroup>
<ItemGroup Condition="'$(TestProject)'!='true'">
<!-- Annotates .NET assemblies with repository information including SHA -->
<!-- Sentry uses this to link directly to GitHub at the exact version/file/line -->
<!-- This is built-in on .NET 8 and can be removed once the project is updated -->
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
</ItemGroup>
<!-- Sentry specific configuration: Only in Release mode -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- https://docs.sentry.io/platforms/dotnet/configuration/msbuild/ -->
<!-- OrgSlug, ProjectSlug and AuthToken are required.
They can be set below, via argument to 'msbuild -p:' or environment variable -->
<SentryOrg></SentryOrg>
<SentryProject></SentryProject>
<SentryUrl></SentryUrl> <!-- If empty, assumed to be sentry.io -->
<SentryAuthToken></SentryAuthToken> <!-- Use env var instead: SENTRY_AUTH_TOKEN -->
<!-- Upload PDBs to Sentry, enabling stack traces with line numbers and file paths
without the need to deploy the application with PDBs -->
<SentryUploadSymbols>true</SentryUploadSymbols>
<!-- Source Link settings -->
<!-- https://github.com/dotnet/sourcelink/blob/main/docs/README.md#publishrepositoryurl -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<!-- Embeds all source code in the respective PDB. This can make it a bit bigger but since it'll be uploaded
to Sentry and not distributed to run on the server, it helps debug crashes while making releases smaller -->
<EmbedAllSources>true</EmbedAllSources>
</PropertyGroup>
<!-- Standard testing packages -->
<ItemGroup Condition="'$(TestProject)'=='true'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NunitXml.TestLogger" Version="3.0.131" />
</ItemGroup>

View File

@@ -55,6 +55,16 @@ namespace NzbDrone.Automation.Test
_page.Find(By.LinkText("Blocklist")).Should().NotBeNull();
}
[Test]
public void wanted_page()
{
_page.WantedNavIcon.Click();
_page.WaitForNoSpinner();
_page.Find(By.LinkText("Missing")).Should().NotBeNull();
_page.Find(By.LinkText("Cutoff Unmet")).Should().NotBeNull();
}
[Test]
public void system_page()
{

View File

@@ -57,6 +57,8 @@ namespace NzbDrone.Automation.Test.PageModel
public IWebElement ActivityNavIcon => Find(By.LinkText("Activity"));
public IWebElement WantedNavIcon => Find(By.LinkText("Wanted"));
public IWebElement SettingNavIcon => Find(By.LinkText("Settings"));
public IWebElement SystemNavIcon => Find(By.PartialLinkText("System"));

View File

@@ -1,10 +1,12 @@
using System.Collections.Generic;
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Options;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
using NzbDrone.Test.Common;
@@ -43,6 +45,26 @@ namespace NzbDrone.Common.Test
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.WriteAllText(configFile, It.IsAny<string>()))
.Callback<string, string>((p, t) => _configFileContents = t);
Mocker.GetMock<IOptions<AuthOptions>>()
.Setup(v => v.Value)
.Returns(new AuthOptions());
Mocker.GetMock<IOptions<AppOptions>>()
.Setup(v => v.Value)
.Returns(new AppOptions());
Mocker.GetMock<IOptions<ServerOptions>>()
.Setup(v => v.Value)
.Returns(new ServerOptions());
Mocker.GetMock<IOptions<LogOptions>>()
.Setup(v => v.Value)
.Returns(new LogOptions());
Mocker.GetMock<IOptions<UpdateOptions>>()
.Setup(v => v.Value)
.Returns(new UpdateOptions());
}
[Test]

View File

@@ -4,6 +4,7 @@ using System.Linq;
using FluentAssertions;
using NLog;
using NUnit.Framework;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation.Sentry;
using NzbDrone.Test.Common;
@@ -43,7 +44,7 @@ namespace NzbDrone.Common.Test.InstrumentationTests
[SetUp]
public void Setup()
{
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111");
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111", Mocker.GetMock<IAppFolderInfo>().Object);
}
private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message)

View File

@@ -10,6 +10,7 @@ using NUnit.Framework;
using NzbDrone.Common.Composition.Extensions;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Common.Options;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Datastore.Extensions;
using NzbDrone.Core.Lifecycle;
@@ -29,10 +30,16 @@ namespace NzbDrone.Common.Test
.AddNzbDroneLogger()
.AutoAddServices(Bootstrap.ASSEMBLIES)
.AddDummyDatabase()
.AddDummyLogDatabase()
.AddStartupContext(new StartupContext("first", "second"));
container.RegisterInstance(new Mock<IHostLifetime>().Object);
container.RegisterInstance(new Mock<IOptions<PostgresOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<AppOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<AuthOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<ServerOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<LogOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<UpdateOptions>>().Object);
var serviceProvider = container.GetServiceProvider();

View File

@@ -153,7 +153,11 @@ namespace NzbDrone.Common.Disk
{
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
return Directory.EnumerateDirectories(path);
return Directory.EnumerateDirectories(path, "*", new EnumerationOptions
{
AttributesToSkip = FileAttributes.System,
IgnoreInaccessible = true
});
}
public IEnumerable<string> GetFiles(string path, bool recursive)

View File

@@ -41,7 +41,7 @@ namespace NzbDrone.Common.Instrumentation
RegisterDebugger();
}
RegisterSentry(updateApp);
RegisterSentry(updateApp, appFolderInfo);
if (updateApp)
{
@@ -62,7 +62,7 @@ namespace NzbDrone.Common.Instrumentation
LogManager.ReconfigExistingLoggers();
}
private static void RegisterSentry(bool updateClient)
private static void RegisterSentry(bool updateClient, IAppFolderInfo appFolderInfo)
{
string dsn;
@@ -77,7 +77,7 @@ namespace NzbDrone.Common.Instrumentation
: "https://998b4673d4c849ccb5277b5966ed5bc2@sentry.servarr.com/10";
}
var target = new SentryTarget(dsn)
var target = new SentryTarget(dsn, appFolderInfo)
{
Name = "sentryTarget",
Layout = "${message}"

View File

@@ -10,6 +10,7 @@ using NLog.Common;
using NLog.Targets;
using Npgsql;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using Sentry;
namespace NzbDrone.Common.Instrumentation.Sentry
@@ -105,7 +106,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
public bool FilterEvents { get; set; }
public bool SentryEnabled { get; set; }
public SentryTarget(string dsn)
public SentryTarget(string dsn, IAppFolderInfo appFolderInfo)
{
_sdk = SentrySdk.Init(o =>
{
@@ -113,9 +114,33 @@ namespace NzbDrone.Common.Instrumentation.Sentry
o.AttachStacktrace = true;
o.MaxBreadcrumbs = 200;
o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}";
o.BeforeSend = x => SentryCleanser.CleanseEvent(x);
o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x);
o.SetBeforeSend(x => SentryCleanser.CleanseEvent(x));
o.SetBeforeBreadcrumb(x => SentryCleanser.CleanseBreadcrumb(x));
o.Environment = BuildInfo.Branch;
// Crash free run statistics (sends a ping for healthy and for crashes sessions)
o.AutoSessionTracking = true;
// Caches files in the event device is offline
// Sentry creates a 'sentry' sub directory, no need to concat here
o.CacheDirectoryPath = appFolderInfo.GetAppDataPath();
// default environment is production
if (!RuntimeInfo.IsProduction)
{
if (RuntimeInfo.IsDevelopment)
{
o.Environment = "development";
}
else if (RuntimeInfo.IsTesting)
{
o.Environment = "testing";
}
else
{
o.Environment = "other";
}
}
});
InitializeScope();
@@ -133,7 +158,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
{
SentrySdk.ConfigureScope(scope =>
{
scope.User = new User
scope.User = new SentryUser
{
Id = HashUtil.AnonymousToken()
};
@@ -336,13 +361,21 @@ namespace NzbDrone.Common.Instrumentation.Sentry
}
}
var level = LoggingLevelMap[logEvent.Level];
var sentryEvent = new SentryEvent(logEvent.Exception)
{
Level = LoggingLevelMap[logEvent.Level],
Level = level,
Logger = logEvent.LoggerName,
Message = logEvent.FormattedMessage
};
if (level is SentryLevel.Fatal && logEvent.Exception is not null)
{
// Usages of 'fatal' here indicates the process will crash. In Sentry this is represented with
// the 'unhandled' exception flag
logEvent.Exception.SetSentryMechanism("Logger.Fatal", "Logger.Fatal was called", false);
}
sentryEvent.SetExtras(extras);
sentryEvent.SetFingerprint(fingerPrint);

View File

@@ -0,0 +1,8 @@
namespace NzbDrone.Common.Options;
public class AppOptions
{
public string InstanceName { get; set; }
public string Theme { get; set; }
public bool? LaunchBrowser { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace NzbDrone.Common.Options;
public class AuthOptions
{
public string ApiKey { get; set; }
public bool? Enabled { get; set; }
public string Method { get; set; }
public string Required { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace NzbDrone.Common.Options;
public class LogOptions
{
public string Level { get; set; }
public bool? FilterSentryEvents { get; set; }
public int? Rotate { get; set; }
public bool? Sql { get; set; }
public string ConsoleLevel { get; set; }
public bool? AnalyticsEnabled { get; set; }
public string SyslogServer { get; set; }
public int? SyslogPort { get; set; }
public string SyslogLevel { get; set; }
public bool? DbEnabled { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace NzbDrone.Common.Options;
public class ServerOptions
{
public string UrlBase { get; set; }
public string BindAddress { get; set; }
public int? Port { get; set; }
public bool? EnableSsl { get; set; }
public int? SslPort { get; set; }
public string SslCertPath { get; set; }
public string SslCertPassword { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace NzbDrone.Common.Options;
public class UpdateOptions
{
public string Mechanism { get; set; }
public bool? Automatically { get; set; }
public string ScriptPath { get; set; }
public string Branch { get; set; }
}

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