1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-03-05 13:21:25 -05:00

Compare commits

..

100 Commits

Author SHA1 Message Date
Weblate
886db23c58 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Maxime Surrel <maxime.surrel@live.fr>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
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/ru/
Translation: Servarr/Radarr
2025-03-24 15:48:02 +02:00
Bogdan
b646386e77 Update state with the filtered movie file languages
Due to filtering the languages in /moviefile/bulk to not allow Any or Original, we're updating the state with the actual languages returned by the API.
2025-03-24 12:56:30 +02:00
Servarr
4aa259a666 Automated API Docs update 2025-03-23 18:42:37 +02:00
Bogdan
35f1a61bf8 Fixed: Updating movie files via Manage Files 2025-03-23 18:33:53 +02:00
Bogdan
1d855aed00 Deprecate /api/v3/moviefile/editor 2025-03-23 18:33:53 +02:00
Bogdan
f7da5b0866 Bump version to 5.21.1 2025-03-23 09:46:34 +02:00
Mark McDowall
682cc70acf Fixed: Drop downs flickering in some cases
(cherry picked from commit 3b024443c5447b7638a69a99809bf44b2419261f)

Closes #10869
2025-03-22 19:49:12 +02:00
Weblate
9d624b07ce Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Alex <despedo@gmail.com>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
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/uk/
Translation: Servarr/Radarr
2025-03-22 16:34:03 +02:00
Bogdan
2afb41498d Fixed: Improve Movie Details loading 2025-03-22 16:32:47 +02:00
Bogdan
a0679fcf11 Fixed: Don't allow Any or Original for movie files 2025-03-22 15:03:43 +02:00
Weblate
df4a69ac02 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Alex <despedo@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: theman824 <h.confirmation@gmx.de>
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
2025-03-21 18:49:59 +02:00
Bogdan
2c8d8ff2d6 Translate indexer settings
Co-authored-by: Stevie Robinson <stevie.robinson@gmail.com>
2025-03-19 16:32:21 +02:00
Bogdan
0593568065 Fixed: Close modal when deleting movie from index
Fixes #10937
2025-03-19 01:03:39 +02:00
Bogdan
25aa719ad6 Bump NLog, Npgsql, System.Memory and System.ValueTuple 2025-03-18 14:18:00 +02:00
Bogdan
3ab61a2fee Bump version to 5.21.0 2025-03-18 14:13:07 +02:00
Weblate
954a040d6e Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translation: Servarr/Radarr
2025-03-18 12:33:26 +02:00
Mark McDowall
905b23618a Improve logging when login fails due to CryptographicException
(cherry picked from commit 1449941471cbb8885e9298317b9a30f2576d7941)
2025-03-16 13:12:41 +02:00
Bogdan
8decd5d8e1 Bump version to 5.20.2 2025-03-16 10:45:58 +02:00
Bogdan
8b5b177d16 New: Display indexer in download failed details 2025-03-16 01:41:37 +02:00
Bogdan
e6c6fceff8 Fixed: Inherit indexer, size and release group for marked as failed history 2025-03-16 01:41:37 +02:00
Weblate
dcc8b28a07 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
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/hu/
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/sv/
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/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_TW/
Translation: Servarr/Radarr
2025-03-15 21:06:54 +02:00
Stevie Robinson
02baf4d7a4 Translate Frontend Components and Helpers
(cherry picked from commit e777b7018481b18ef18f1116f75983a037bf0849)

Closes #8995
2025-03-15 18:15:29 +02:00
Servarr
22ec1fe492 Multiple Translations updated by Weblate (#10925)
ignore-downstream






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/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translation: Servarr/Radarr

Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
2025-03-15 14:13:27 +02:00
fezster
a7dbdadd21 New: Add HDR Type to XBMC metadata video stream details (#10906)
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2025-03-12 14:33:46 +02:00
Bogdan
93581e4a2f Fixed: Spinning icon on toggling movie monitoring 2025-03-11 23:07:08 +02:00
Bogdan
4c8da09df6 Fixed: Movie Details crashing on invalid collection 2025-03-11 16:14:29 +02:00
Bogdan
89666175a6 Update recommendation message against using uTorrent
(cherry picked from commit 6d8c3f15b343a24fc31a212463af8ed2b5792508)
2025-03-11 11:32:13 +02:00
Mark McDowall
7a33e156a3 New: Truncate button text
(cherry picked from commit 093ee5b88db0470426f6132e66a5893e5cf89bab)
2025-03-11 11:29:31 +02:00
Mark McDowall
c7c07404b0 Improve wrapping of text in sidebar
(cherry picked from commit f58dfc5605738ebccdd6adc6f1ca2a7843c086b2)
2025-03-11 11:29:20 +02:00
Mark McDowall
abeeee9363 Upgrade 'eslint-plugin-react-hooks' to 5.2.0
(cherry picked from commit c86822b114bd0b7276b40cdf2bb6181ef35db3dc)
2025-03-11 11:27:52 +02:00
Bogdan
23c30734d2 Convert QualityProfileName to TypeScript
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2025-03-09 22:02:04 +02:00
Bogdan
939e45e646 Disable left/right arrow navigation when a modal is open on Movie Details 2025-03-09 21:46:08 +02:00
Weblate
16ceba2392 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translation: Servarr/Radarr
2025-03-09 21:20:18 +02:00
Bogdan
94d620d878 Clear search results when switching between movies 2025-03-09 21:16:48 +02:00
hhjuhl
ee0db93a0a Use 'text-wrap: balance' for text wrapping on overview
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
(cherry picked from commit 160151c6e000c6620ba2e04ca6245317c5c9ba16)

Closes #10729
2025-03-09 19:27:24 +02:00
Bogdan
f815b31c33 Convert Movie Details to TypeScript
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2025-03-09 19:26:44 +02:00
Bogdan
c078191b3d Bump version to 5.20.1 2025-03-09 11:53:09 +02:00
Bogdan
653b358fd3 Convert Delete Movie Modal to TypeScript
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2025-03-08 16:36:12 +02:00
Bogdan
6a7ed22b44 Convert Movie History to TypeScript
Closes #10755

Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2025-03-08 16:04:08 +02:00
Mark McDowall
779292490a Convert SelectMovieRow to TypeScript
(cherry picked from commit 32ce09648cb9eb13c46b060f2665f3ce837261f2)
2025-03-08 15:16:12 +02:00
Mark McDowall
e4e96fc7f9 Convert Preview Rename to TypeScript
(cherry picked from commit a2fd23c84d0a9d01864119d2e643970845c9e49e)
2025-03-08 15:16:12 +02:00
Weblate
049bf7715e Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
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/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2025-03-08 15:16:04 +02:00
Bogdan
df4dfaac0b Bump SixLabors.ImageSharp to 3.1.7 2025-03-07 19:17:58 +02:00
Bogdan
89c96b0a80 Increase input sizes in edit movie modal
Closes #10749
2025-03-07 19:03:28 +02:00
Bogdan
7db12b6e58 Convert EditMovieModal to TypeScript
Towards #10700

Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2025-03-07 19:03:28 +02:00
Bogdan
28dee7bc01 Convert MoveMovieModal to TypeScript
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2025-03-07 19:03:19 +02:00
Bogdan
8ec60eb0a6 Convert Movie Formats/Status/CollectionLabel to TypeScript 2025-03-07 16:43:37 +02:00
Weblate
102849a697 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Chong Yao Jun <yaojun12345678910@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Stan Ulbrych <stanulbrych@gmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: pbarone <pbarone@live.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/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pl/
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/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_Hans/
Translation: Servarr/Radarr
2025-03-07 14:03:05 +02:00
Bogdan
95da7d7b47 Convert Interactive Search to TypeScript 2025-03-04 15:15:04 +02:00
Weblate
22b5739967 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Eduardo045 <eduardomeirelles045@gmail.com>
Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Volodymyr <minecrafter7893@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: corwin007x <skopal.ondrej@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
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/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translation: Servarr/Radarr
2025-03-04 14:57:54 +02:00
Bogdan
cfba047d80 Fixed: Parsing some titles with FRA as French 2025-03-04 14:46:11 +02:00
Bogdan
576d404e70 Fixed: Replace diacritics in Clean Title naming tokens 2025-03-02 07:19:13 +02:00
Bogdan
5959d4e51a Fixed: Instance name must contain application name 2025-02-26 03:59:22 +02:00
Chaz Harris
2aca6c6e1d Bump devcontainer nodejs version to 20
(cherry picked from commit d8222c066c04d5219a21a6e7f9f3571a67e8dcca)
2025-02-25 20:06:02 +02:00
Bogdan
e8bbe0ee9f Bump Polly to 8.5.2 2025-02-25 19:51:19 +02:00
Bogdan
66332a110a Bump version to 5.20.0 2025-02-25 19:50:48 +02:00
Bogdan
36c66deb4b Recommend against using uTorrent 2025-02-25 12:50:11 +02:00
Weblate
edec432244 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: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
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/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translation: Servarr/Radarr
2025-02-24 19:28:00 +02:00
Bogdan
554e15d438 New: Watch list sorting and rate limit for Trakt Import Lists 2025-02-23 14:59:38 +02:00
Bogdan
553645a07c Bump version to 5.19.2 2025-02-23 12:14:28 +02:00
Bogdan
7de7e83c5b New: Add Blu-ray link to movie details 2025-02-23 00:03:48 +02:00
Bogdan
b7a46bedb0 Fixed: Avoid checking for free space if other specifications fail first 2025-02-22 21:33:38 +02:00
Weblate
0925769377 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Al3xPdx007 <constantin.pdx@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Pablo <pablo@pabloarraiz.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: pelnoph <pierre.regnier.1984@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/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
2025-02-22 18:34:51 +02:00
Servarr
72244362fe Automated API Docs update 2025-02-22 18:33:37 +02:00
Mark McDowall
c6526c34e9 Cleanse console log messages
(cherry picked from commit 609e964794e17343f63e1ecff3fef323e3d284ff)
2025-02-19 15:44:15 +02:00
Bogdan
efa2913dbc Translate Trakt popular list types 2025-02-19 15:28:05 +02:00
Mark McDowall
35c22a4ffa Fixed: Only show Additional Parameters on Trakt Popular list
(cherry picked from commit b122ee967009d53432f3d1dd196132487f3999e1)
2025-02-19 15:15:17 +02:00
Stevie Robinson
66d96e21da Fixed: Fallback to Instance Name for Discord notifications
(cherry picked from commit b99e06acc0a3ecae2857d9225b35424c82c67a2b)
2025-02-19 14:52:30 +02:00
Bogdan
36d4e9e6cd New: Movie Requested filter for interactive search 2025-02-18 04:42:10 +02:00
Weblate
7189d7b15c Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: haru4a <haru4as95@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translation: Servarr/Radarr
2025-02-16 23:04:54 +02:00
Servarr
6e80113987 Automated API Docs update 2025-02-16 23:03:09 +02:00
Bogdan
bb8a0dda63 Fixed: Processing existing movie files via Manage Files 2025-02-16 21:36:10 +02:00
Bogdan
525ed65687 Fix download links for FileList when passkey contains spaces 2025-02-16 12:19:17 +02:00
Bogdan
3fbccc6af3 Bump version to 5.19.2 2025-02-16 12:19:04 +02:00
Bogdan
8e10eecfac Fixed: Close Metadata settings modal on saving 2025-02-15 13:34:21 +02:00
Bogdan
a3b1512552 Fixed: Parsing some titles with FRE as French and ITA as Italian 2025-02-13 17:31:39 +02:00
epmt7w3ugk
d375b5ffbe Fixed: Parse GER/DE releases as German language
Fix parsing for German language to correctly detect "GER" and "DE"
Update test for GER/DE language parsing.
2025-02-10 17:17:43 +02:00
Bogdan
884abc0368 Bump version to 5.19.1 2025-02-09 17:50:50 +02:00
Weblate
f8da7aae03 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Gionatan Spedicato <natanoig444@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
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/cs/
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/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translation: Servarr/Radarr
2025-02-07 19:14:05 -06:00
Connor Gallopo
c165118d4d Update README.md
Update Copyright Date
2025-02-07 14:01:42 -06:00
Robin Dadswell
b3dd571a92 New: Migrated StevenLu URL to new URL 2025-02-06 03:41:33 +02:00
Bogdan
dd900eb739 Building docs on ARM
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
(cherry picked from commit 147e732c9ca7a4c289d4f6386f1277650e11f15b)
2025-02-06 00:43:53 +02:00
Bogdan
66aae0c91c Fixed: Reject multi-part files with P1, P2, etc. 2025-02-04 03:40:06 +02:00
Servarr
d888a0a2b3 Automated API Docs update 2025-02-03 21:31:13 +02:00
Bogdan
cb5416a18c Improve message for unknown movie rejection in release searching 2025-02-03 18:06:48 +02:00
Bogdan
7977e0be05 Add reason enum to decision engine rejections
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2025-02-03 17:46:13 +02:00
Bogdan
cd836fef38 Bump version to 5.19.0 2025-02-03 14:12:21 +02:00
Mark McDowall
b0bfbe767c Add MediaInfo AudioLanguagesAll and update styling
(cherry picked from commit 572b8620c9693f6824ae0919d6a37ba69c7590b1)
2025-02-02 15:16:41 +02:00
Bogdan
528b93dabe Fixed: Format bitrate for primary streams in media info
Co-authored-by: Mark McDowall <markus.mcd5@gmail.com>
2025-02-02 13:58:44 +02:00
Bogdan
1edcbee5e1 Bump version to 5.18.4 2025-02-02 12:49:50 +02:00
Bogdan
8853dced9f Fixed: Health warning for downloading inside root folders
(cherry picked from commit 1e9fd02e9d2bf57247adcac5728e2a0d2b084b86)
2025-02-01 23:36:10 +02:00
Mark McDowall
c7aa1bae5e Fixed: Ignore special folders inside Blackhole watch folders
(cherry picked from commit e79dd6f8e689617b1fd9f96c639ac300669112c5)
2025-02-01 23:35:45 +02:00
Bogdan
405ae77070 New: Prefer newer Usenet releases
(cherry picked from commit 6a439f03273b376feda713ef04a6912fc3af9d0a)
2025-02-01 23:35:31 +02:00
Weblate
6236bc9b4f Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: Mailme Dashite <mailmedashite@protonmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translation: Servarr/Radarr
2025-01-31 23:30:40 +02:00
Bogdan
743c977e5b New: Refresh cache for tracked queue on movies update 2025-01-31 23:29:41 +02:00
Bogdan
c0e5646f07 Bump Polly and NLog.Layouts.ClefJsonLayout 2025-01-26 15:42:43 +02:00
Weblate
10094b4e66 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Craze <christian.strey@gmail.com>
Co-authored-by: Dimitar \"Topper\" Maznekov <d.maznekov@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: ηg <jonas.konrath@icloud.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/fa/
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/hu/
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/ru/
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/
Translation: Servarr/Radarr
2025-01-26 15:00:33 +02:00
Bogdan
d923406f08 Bump version to 5.18.3 2025-01-26 14:48:34 +02:00
Bogdan
69a9c72286 Fixed: Loading movies with duplicated translations 2025-01-26 14:47:13 +02:00
Bogdan
55b9477a01 Fixed: Cleanup duplicated movie translations 2025-01-26 14:47:13 +02:00
339 changed files with 7946 additions and 6415 deletions

View File

@@ -6,7 +6,7 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "16",
"version": "20",
"nvmVersion": "latest"
}
},

View File

@@ -87,4 +87,4 @@ This project is also supported by DigitalOcean
### License
* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
* Copyright 2010-2024
* Copyright 2010-2025

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '5.18.2'
majorVersion: '5.21.1'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'

15
docs.sh
View File

@@ -1,13 +1,18 @@
#!/bin/bash
set -e
FRAMEWORK="net6.0"
PLATFORM=$1
ARCHITECTURE="${2:-x64}"
if [ "$PLATFORM" = "Windows" ]; then
RUNTIME="win-x64"
RUNTIME="win-$ARCHITECTURE"
elif [ "$PLATFORM" = "Linux" ]; then
RUNTIME="linux-x64"
RUNTIME="linux-$ARCHITECTURE"
elif [ "$PLATFORM" = "Mac" ]; then
RUNTIME="osx-x64"
RUNTIME="osx-$ARCHITECTURE"
else
echo "Platform must be provided as first arguement: Windows, Linux or Mac"
echo "Platform must be provided as first argument: Windows, Linux or Mac"
exit 1
fi
@@ -35,7 +40,7 @@ dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p
dotnet new tool-manifest
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/$application" v3 &
dotnet tool run swagger tofile --output ./src/Radarr.Api.V3/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v3 &
sleep 45

View File

@@ -165,7 +165,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
}
if (eventType === 'downloadFailed') {
const { message } = data as DownloadFailedHistory;
const { message, indexer } = data as DownloadFailedHistory;
return (
<DescriptionList>
@@ -179,6 +179,10 @@ function HistoryDetails(props: HistoryDetailsProps) {
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
) : null}
{indexer ? (
<DescriptionListItem title={translate('Indexer')} data={indexer} />
) : null}
{message ? (
<DescriptionListItem title={translate('Message')} data={message} />
) : null}

View File

@@ -37,7 +37,7 @@ interface HistoryDetailsModalProps {
sourceTitle: string;
data: HistoryData;
downloadId?: string;
isMarkingAsFailed: boolean;
isMarkingAsFailed?: boolean;
onMarkAsFailedPress: () => void;
onModalClose: () => void;
}

View File

@@ -82,8 +82,7 @@ class AddNewMovie extends Component {
const {
error,
items,
hasExistingMovies,
colorImpairedMode
hasExistingMovies
} = this.props;
const term = this.state.term;
@@ -150,7 +149,6 @@ class AddNewMovie extends Component {
return (
<AddNewMovieSearchResultConnector
key={item.tmdbId}
colorImpairedMode={colorImpairedMode}
{...item}
/>
);
@@ -223,8 +221,7 @@ AddNewMovie.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
hasExistingMovies: PropTypes.bool.isRequired,
onMovieLookupChange: PropTypes.func.isRequired,
onClearMovieLookup: PropTypes.func.isRequired,
colorImpairedMode: PropTypes.bool.isRequired
onClearMovieLookup: PropTypes.func.isRequired
};
export default AddNewMovie;

View File

@@ -6,7 +6,6 @@ import { clearAddMovie, lookupMovie } from 'Store/Actions/addMovieActions';
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import parseUrl from 'Utilities/String/parseUrl';
@@ -17,15 +16,13 @@ function createMapStateToProps() {
(state) => state.addMovie,
(state) => state.movies.items.length,
(state) => state.router.location,
createUISettingsSelector(),
(addMovie, existingMoviesCount, location, uiSettings) => {
(addMovie, existingMoviesCount, location) => {
const { params } = parseUrl(location.search);
return {
...addMovie,
term: params.term,
hasExistingMovies: existingMoviesCount > 0,
colorImpairedMode: uiSettings.enableColorImpairedMode
hasExistingMovies: existingMoviesCount > 0
};
}
);

View File

@@ -74,12 +74,9 @@ class AddNewMovieSearchResult extends Component {
isExistingMovie,
isExcluded,
isSmallScreen,
colorImpairedMode,
id,
monitored,
isAvailable,
movieFile,
queueItem,
runtime,
movieRuntimeFormat,
certification
@@ -285,14 +282,12 @@ class AddNewMovieSearchResult extends Component {
{
isExistingMovie && isSmallScreen &&
<MovieStatusLabel
status={status}
hasMovieFiles={hasMovieFile}
movieId={existingMovieId}
monitored={monitored}
isAvailable={isAvailable}
queueItem={queueItem}
id={id}
hasMovieFiles={hasMovieFile}
status={status}
useLabel={true}
colorImpairedMode={colorImpairedMode}
/>
}
</div>
@@ -337,12 +332,9 @@ AddNewMovieSearchResult.propTypes = {
isExistingMovie: PropTypes.bool.isRequired,
isExcluded: PropTypes.bool,
isSmallScreen: PropTypes.bool.isRequired,
id: PropTypes.number,
monitored: PropTypes.bool.isRequired,
isAvailable: PropTypes.bool.isRequired,
movieFile: PropTypes.object,
queueItem: PropTypes.object,
colorImpairedMode: PropTypes.bool,
runtime: PropTypes.number.isRequired,
movieRuntimeFormat: PropTypes.string.isRequired,
certification: PropTypes.string

View File

@@ -8,19 +8,16 @@ function createMapStateToProps() {
return createSelector(
createExistingMovieSelector(),
createDimensionsSelector(),
(state) => state.queue.details.items,
(state) => state.movieFiles.items,
(state, { internalId }) => internalId,
(state) => state.settings.ui.item.movieRuntimeFormat,
(isExistingMovie, dimensions, queueItems, movieFiles, internalId, movieRuntimeFormat) => {
const queueItem = queueItems.find((item) => internalId > 0 && item.movieId === internalId);
(isExistingMovie, dimensions, movieFiles, internalId, movieRuntimeFormat) => {
const movieFile = movieFiles.find((item) => internalId > 0 && item.movieId === internalId);
return {
existingMovieId: internalId,
isExistingMovie,
isSmallScreen: dimensions.isSmallScreen,
queueItem,
movieFile,
movieRuntimeFormat
};

View File

@@ -10,7 +10,7 @@ import CollectionConnector from 'Collection/CollectionConnector';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector';
import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector';
import MovieDetailsPage from 'Movie/Details/MovieDetailsPage';
import MovieIndex from 'Movie/Index/MovieIndex';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
@@ -67,7 +67,7 @@ function AppRoutes() {
<Route path="/add/discover" component={DiscoverMovieConnector} />
<Route path="/movie/:titleSlug" component={MovieDetailsPageConnector} />
<Route path="/movie/:titleSlug" component={MovieDetailsPage} />
{/*
Calendar

View File

@@ -1,15 +1,19 @@
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
import HistoryAppState from './HistoryAppState';
import ExtraFilesAppState from './ExtraFilesAppState';
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import MovieBlocklistAppState from './MovieBlocklistAppState';
import MovieCollectionAppState from './MovieCollectionAppState';
import MovieCreditAppState from './MovieCreditAppState';
import MovieFilesAppState from './MovieFilesAppState';
import MoviesAppState, { MovieIndexAppState } from './MoviesAppState';
import OrganizePreviewAppState from './OrganizePreviewAppState';
import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
@@ -50,6 +54,7 @@ export interface CustomFilter {
export interface AppSectionState {
isConnected: boolean;
isReconnecting: boolean;
isSidebarVisible: boolean;
version: string;
prevVersion?: string;
dimensions: {
@@ -64,16 +69,21 @@ interface AppState {
blocklist: BlocklistAppState;
calendar: CalendarAppState;
commands: CommandAppState;
extraFiles: ExtraFilesAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
movieBlocklist: MovieBlocklistAppState;
movieCollections: MovieCollectionAppState;
movieCredits: MovieCreditAppState;
movieFiles: MovieFilesAppState;
movieHistory: MovieHistoryAppState;
movieIndex: MovieIndexAppState;
movies: MoviesAppState;
organizePreview: OrganizePreviewAppState;
parse: ParseAppState;
paths: PathsAppState;
queue: QueueAppState;
releases: ReleasesAppState;
rootFolders: RootFolderAppState;
settings: SettingsAppState;
system: SystemAppState;

View File

@@ -0,0 +1,6 @@
import AppSectionState from 'App/State/AppSectionState';
import { ExtraFile } from 'MovieFile/ExtraFile';
type ExtraFilesAppState = AppSectionState<ExtraFile>;
export default ExtraFilesAppState;

View File

@@ -5,6 +5,8 @@ import AppSectionState, {
} from 'App/State/AppSectionState';
import History from 'typings/History';
export type MovieHistoryAppState = AppSectionState<History>;
interface HistoryAppState
extends AppSectionState<History>,
AppSectionFilterState<History>,

View File

@@ -0,0 +1,6 @@
import AppSectionState from 'App/State/AppSectionState';
import Blocklist from 'typings/Blocklist';
type MovieBlocklistAppState = AppSectionState<Blocklist>;
export default MovieBlocklistAppState;

View File

@@ -1,7 +1,11 @@
import AppSectionState from 'App/State/AppSectionState';
import AppSectionState, {
AppSectionSaveState,
} from 'App/State/AppSectionState';
import MovieCollection from 'typings/MovieCollection';
interface MovieCollectionAppState extends AppSectionState<MovieCollection> {
interface MovieCollectionAppState
extends AppSectionState<MovieCollection>,
AppSectionSaveState {
itemMap: Record<number, number>;
}

View File

@@ -64,6 +64,8 @@ interface MoviesAppState
deleteOptions: {
addImportExclusion: boolean;
};
pendingChanges: Partial<Movie>;
}
export default MoviesAppState;

View File

@@ -0,0 +1,13 @@
import ModelBase from 'App/ModelBase';
import AppSectionState from 'App/State/AppSectionState';
export interface OrganizePreviewModel extends ModelBase {
movieId: number;
movieFileId: number;
existingPath: string;
newPath: string;
}
type OrganizePreviewAppState = AppSectionState<OrganizePreviewModel>;
export default OrganizePreviewAppState;

View File

@@ -0,0 +1,10 @@
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import Release from 'typings/Release';
interface ReleasesAppState
extends AppSectionState<Release>,
AppSectionFilterState<Release> {}
export default ReleasesAppState;

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import EditMovieModal from 'Movie/Edit/EditMovieModal';
import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
import MoviePoster from 'Movie/MoviePoster';
import translate from 'Utilities/String/translate';
@@ -172,7 +172,7 @@ class CollectionMovie extends Component {
collectionId={collectionId}
/>
<EditMovieModalConnector
<EditMovieModal
isOpen={isEditMovieModalOpen}
movieId={id}
onModalClose={this.onEditMovieModalClose}

View File

@@ -11,7 +11,7 @@ import IconButton from 'Components/Link/IconButton';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import { icons, sizes } from 'Helpers/Props';
import MovieGenres from 'Movie/MovieGenres';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
import translate from 'Utilities/String/translate';
@@ -212,7 +212,7 @@ class CollectionOverview extends Component {
/>
<span className={styles.qualityProfileName}>
{
<QualityProfileNameConnector
<QualityProfileName
qualityProfileId={qualityProfileId}
/>
}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import StackTrace from 'stacktrace-js';
import translate from 'Utilities/String/translate';
import styles from './ErrorBoundaryError.css';
interface ErrorBoundaryErrorProps {
@@ -18,7 +19,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
className = styles.container,
messageClassName = styles.message,
detailsClassName = styles.details,
message = 'There was an error loading this content',
message = translate('ErrorLoadingContent'),
error,
info,
} = props;

View File

@@ -3,7 +3,10 @@ import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const statusTagList = [
{ id: 'tba', name: 'TBA' },
{ id: 'tba',
get name() {
return translate('Tba');
} },
{
id: 'announced',
get name() {

View File

@@ -53,7 +53,7 @@ function CustomFiltersModalContent(props) {
<div className={styles.addButtonContainer}>
<Button onPress={onAddCustomFilter}>
Add Custom Filter
{translate('AddCustomFilter')}
</Button>
</div>
</ModalBody>

View File

@@ -20,6 +20,8 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 10;
function isArrowKey(keyCode) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@@ -137,18 +139,9 @@ class EnhancedSelectInput extends Component {
// Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom
} = data.offsets.reference;
const windowHeight = window.innerHeight;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
return data;
};
@@ -463,6 +456,10 @@ class EnhancedSelectInput extends Component {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport'
}
}}
>

View File

@@ -1,9 +1,11 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchIndexers } from 'Store/Actions/settingsActions';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
@@ -18,15 +20,17 @@ function createMapStateToProps() {
items
} = indexers;
const values = items.sort(sortByProp('name')).map((indexer) => ({
key: indexer.id,
value: indexer.name
}));
const values = _.map(items.sort(sortByProp('name')), (indexer) => {
return {
key: indexer.id,
value: indexer.name
};
});
if (includeAny) {
values.unshift({
key: 0,
value: '(Any)'
value: `(${translate('Any')})`
});
}

View File

@@ -33,7 +33,7 @@ function createMapStateToProps() {
if (includeMixed) {
values.unshift({
key: 'mixed',
value: '(Mixed)',
value: `(${translate('Mixed')})`,
isDisabled: true
});
}

View File

@@ -55,7 +55,7 @@ function createMapStateToProps() {
values.push({
key: ADD_NEW_KEY,
value: 'Add a new path'
value: translate('AddANewPath')
});
return {

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import styles from './RootFolderSelectInputOption.css';
@@ -47,14 +48,14 @@ function RootFolderSelectInputOption(props) {
freeSpace == null ?
null :
<div className={styles.freeSpace}>
{formatBytes(freeSpace)} Free
{translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
</div>
}
{
isMissing ?
<div className={styles.isMissing}>
Missing
{translate('Missing')}
</div> :
null
}
@@ -67,8 +68,8 @@ RootFolderSelectInputOption.propTypes = {
id: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
freeSpace: PropTypes.number,
movieFolder: PropTypes.string,
isMissing: PropTypes.bool,
movieFolder: PropTypes.string,
isMobile: PropTypes.bool.isRequired,
isWindows: PropTypes.bool
};

View File

@@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
import styles from './RootFolderSelectInputSelectedValue.css';
@@ -39,7 +40,7 @@ function RootFolderSelectInputSelectedValue(props) {
{
freeSpace != null && includeFreeSpace &&
<div className={styles.freeSpace}>
{formatBytes(freeSpace)} Free
{translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
</div>
}
</EnhancedSelectInputSelectedValue>

View File

@@ -8,27 +8,37 @@ import styles from './UMaskInput.css';
const umaskOptions = [
{
key: '755',
value: '755 - Owner write, Everyone else read',
get value() {
return translate('Umask755Description', { octal: '755' });
},
hint: 'drwxr-xr-x'
},
{
key: '775',
value: '775 - Owner & Group write, Other read',
get value() {
return translate('Umask775Description', { octal: '775' });
},
hint: 'drwxrwxr-x'
},
{
key: '770',
value: '770 - Owner & Group write',
get value() {
return translate('Umask770Description', { octal: '770' });
},
hint: 'drwxrwx---'
},
{
key: '750',
value: '750 - Owner write, Group read',
get value() {
return translate('Umask750Description', { octal: '750' });
},
hint: 'drwxr-x---'
},
{
key: '777',
value: '777 - Everyone write',
get value() {
return translate('Umask777Description', { octal: '777' });
},
hint: 'drwxrwxrwx'
}
];

View File

@@ -8,17 +8,20 @@ import { kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import styles from './Icon.css';
export type IconName = FontAwesomeIconProps['icon'];
export type IconKind = Extract<Kind, keyof typeof styles>;
export interface IconProps
extends Omit<
FontAwesomeIconProps,
'icon' | 'spin' | 'name' | 'title' | 'size'
> {
containerClassName?: ComponentProps<'span'>['className'];
name: FontAwesomeIconProps['icon'];
kind?: Extract<Kind, keyof typeof styles>;
name: IconName;
kind?: IconKind;
size?: number;
isSpinning?: FontAwesomeIconProps['spin'];
title?: string | (() => string);
title?: string | (() => string) | null;
}
export default function Icon({

View File

@@ -16,6 +16,10 @@
/** Kinds **/
.default {
color: inherit;
}
/** Sizes **/
.small {

View File

@@ -1,6 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'default': string;
'label': string;
'large': string;
'medium': string;

View File

@@ -1,54 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { kinds, sizes } from 'Helpers/Props';
import styles from './InfoLabel.css';
function InfoLabel(props) {
const {
className,
name,
kind,
size,
outline,
children,
...otherProps
} = props;
return (
<span
className={classNames(
className,
styles[kind],
styles[size],
outline && styles.outline
)}
{...otherProps}
>
<div className={styles.name}>
{name}
</div>
<div>
{children}
</div>
</span>
);
}
InfoLabel.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all).isRequired,
size: PropTypes.oneOf(sizes.all).isRequired,
outline: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired
};
InfoLabel.defaultProps = {
className: styles.label,
kind: kinds.DEFAULT,
size: sizes.SMALL,
outline: false
};
export default InfoLabel;

View File

@@ -0,0 +1,41 @@
import classNames from 'classnames';
import React, { ComponentProps, ReactNode } from 'react';
import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
import styles from './InfoLabel.css';
interface InfoLabelProps extends ComponentProps<'span'> {
className?: string;
name: string;
kind?: Extract<Kind, keyof typeof styles>;
size?: Extract<Size, keyof typeof styles>;
outline?: boolean;
children: ReactNode;
}
function InfoLabel({
className = styles.label,
name,
kind = 'default',
size = 'small',
outline = false,
children,
...otherProps
}: InfoLabelProps) {
return (
<span
className={classNames(
className,
styles[kind],
styles[size],
outline && styles.outline
)}
{...otherProps}
>
<div className={styles.name}>{name}</div>
<div>{children}</div>
</span>
);
}
export default InfoLabel;

View File

@@ -1,3 +1,4 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
@@ -7,26 +8,15 @@ const TIMEOUT = 1 / FPS * 1000;
class Marquee extends Component {
static propTypes = {
text: PropTypes.string,
title: PropTypes.string,
hoverToStop: PropTypes.bool,
loop: PropTypes.bool,
className: PropTypes.string
};
constructor(props, context) {
super(props, context);
static defaultProps = {
text: '',
title: '',
hoverToStop: true,
loop: false
};
state = {
animatedWidth: 0,
overflowWidth: 0,
direction: 0
};
this.state = {
animatedWidth: 0,
overflowWidth: 0,
direction: 0
};
}
componentDidMount() {
this.measureText();
@@ -138,7 +128,7 @@ class Marquee extends Component {
ref={(el) => {
this.container = el;
}}
className={`ui-marquee ${this.props.className}`}
className={classNames('ui-marquee', this.props.className)}
style={{ overflow: 'hidden' }}
>
<span
@@ -159,7 +149,7 @@ class Marquee extends Component {
ref={(el) => {
this.container = el;
}}
className={`ui-marquee ${this.props.className}`.trim()}
className={classNames('ui-marquee', this.props.className)}
style={{ overflow: 'hidden' }}
onMouseEnter={this.onHandleMouseEnter}
onMouseLeave={this.onHandleMouseLeave}
@@ -178,4 +168,20 @@ class Marquee extends Component {
}
}
Marquee.propTypes = {
text: PropTypes.string,
title: PropTypes.string,
hoverToStop: PropTypes.bool,
loop: PropTypes.bool,
className: PropTypes.string
};
Marquee.defaultProps = {
text: '',
title: '',
hoverToStop: true,
loop: false,
className: ''
};
export default Marquee;

View File

@@ -58,9 +58,9 @@ class FilterMenu extends Component {
>
<ButtonComponent
iconName={icons.FILTER}
showIndicator={selectedFilterKey !== 'all'}
text={translate('Filter')}
isDisabled={isDisabled}
showIndicator={selectedFilterKey !== 'all'}
/>
<FilterMenuContent

View File

@@ -3,7 +3,7 @@ import React, { Component } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import { align } from 'Helpers/Props';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import getUniqueElementId from 'Utilities/getUniqueElementId';
import styles from './Menu.css';
const sharedPopperOptions = {
@@ -38,8 +38,8 @@ class Menu extends Component {
super(props, context);
this._scheduleUpdate = null;
this._menuButtonId = getUniqueElememtId();
this._menuContentId = getUniqueElememtId();
this._menuButtonId = getUniqueElementId();
this._menuContentId = getUniqueElementId();
this.state = {
isMenuOpen: false,

View File

@@ -18,7 +18,7 @@ function ModalError(props) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Error
{translate('Error')}
</ModalHeader>
<ModalBody>
@@ -26,7 +26,7 @@ function ModalError(props) {
messageClassName={styles.message}
detailsClassName={styles.details}
{...otherProps}
message={translate('ThereWasAnErrorLoadingThisItem')}
message={translate('ErrorLoadingItem')}
/>
</ModalBody>

View File

@@ -56,7 +56,7 @@ function KeyboardShortcutsModalContent(props) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Keyboard Shortcuts
{translate('KeyboardShortcuts')}
</ModalHeader>
<ModalBody>

View File

@@ -92,7 +92,7 @@ class MovieSearchInput extends Component {
if (item.type === ADD_NEW_TYPE) {
return (
<div className={styles.addNewMovieSuggestion}>
Search for {query}
{translate('SearchForQuery', { query })}
</div>
);
}

View File

@@ -79,7 +79,7 @@ class PageHeader extends Component {
<IconButton
className={styles.donate}
name={icons.HEART}
aria-label="Donate"
aria-label={translate('Donate')}
to="https://radarr.video/donate"
size={14}
title={translate('Donate')}

View File

@@ -10,7 +10,7 @@ function PageContentError(props) {
<PageContentBody>
<ErrorBoundaryError
{...props}
message={translate('ThereWasAnErrorLoadingThisPage')}
message={translate('ErrorLoadingPage')}
/>
</PageContentBody>
</div>

View File

@@ -24,6 +24,7 @@
composes: link;
padding: 10px 24px;
padding-left: 35px;
}
.isActiveLink {
@@ -41,10 +42,6 @@
text-align: center;
}
.noIcon {
margin-left: 25px;
}
.status {
float: right;
}

View File

@@ -8,7 +8,6 @@ interface CssExports {
'isActiveParentLink': string;
'item': string;
'link': string;
'noIcon': string;
'status': string;
}
export const cssExports: CssExports;

View File

@@ -63,9 +63,7 @@ class PageSidebarItem extends Component {
</span>
}
<span className={isChildItem ? styles.noIcon : null}>
{typeof title === 'function' ? title() : title}
</span>
{typeof title === 'function' ? title() : title}
{
!!StatusComponent &&

View File

@@ -23,11 +23,14 @@
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
height: 24px;
}
.label {
padding: 0 3px;
max-width: 100%;
max-height: 100%;
color: var(--toolbarLabelColor);
font-size: $extraSmallFontSize;
line-height: calc($extraSmallFontSize + 1px);

View File

@@ -1,58 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import styles from './PageToolbarButton.css';
function PageToolbarButton(props) {
const {
label,
iconName,
spinningName,
isDisabled,
isSpinning,
...otherProps
} = props;
return (
<Link
className={classNames(
styles.toolbarButton,
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled || isSpinning}
{...otherProps}
>
<Icon
name={isSpinning ? (spinningName || iconName) : iconName}
isSpinning={isSpinning}
size={21}
/>
<div className={styles.labelContainer}>
<div className={styles.label}>
{label}
</div>
</div>
</Link>
);
}
PageToolbarButton.propTypes = {
label: PropTypes.string.isRequired,
iconName: PropTypes.object.isRequired,
spinningName: PropTypes.object,
isSpinning: PropTypes.bool,
isDisabled: PropTypes.bool,
onPress: PropTypes.func
};
PageToolbarButton.defaultProps = {
spinningName: icons.SPINNER,
isDisabled: false,
isSpinning: false
};
export default PageToolbarButton;

View File

@@ -0,0 +1,50 @@
import classNames from 'classnames';
import React from 'react';
import Icon, { IconName } from 'Components/Icon';
import Link, { LinkProps } from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import styles from './PageToolbarButton.css';
export interface PageToolbarButtonProps extends LinkProps {
label: string;
iconName: IconName;
spinningName?: IconName;
isSpinning?: boolean;
isDisabled?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
overflowComponent?: React.ComponentType<any>;
}
function PageToolbarButton({
label,
iconName,
spinningName = icons.SPINNER,
isDisabled = false,
isSpinning = false,
overflowComponent,
...otherProps
}: PageToolbarButtonProps) {
return (
<Link
className={classNames(
styles.toolbarButton,
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled || isSpinning}
title={label}
{...otherProps}
>
<Icon
name={isSpinning ? spinningName || iconName : iconName}
isSpinning={isSpinning}
size={21}
/>
<div className={styles.labelContainer}>
<div className={styles.label}>{label}</div>
</div>
</Link>
);
}
export default PageToolbarButton;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { SortDirection } from 'Helpers/Props/sortDirections';
type PropertyFunction<T> = () => T;
@@ -9,6 +10,7 @@ interface Column {
className?: string;
columnLabel?: string;
isSortable?: boolean;
fixedSortDirection?: SortDirection;
isVisible: boolean;
isModifiable?: boolean;
}

View File

@@ -170,11 +170,11 @@ class TableOptionsModal extends Component {
{
canModifyColumns ?
<FormGroup>
<FormLabel>{translate('Columns')}</FormLabel>
<FormLabel>{translate('TableColumns')}</FormLabel>
<div>
<FormInputHelpText
text={translate('TableOptionsColumnsMessage')}
text={translate('TableColumnsHelpText')}
/>
<div className={styles.columns}>

View File

@@ -6,6 +6,7 @@ import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './TablePager.css';
class TablePager extends Component {
@@ -156,7 +157,7 @@ class TablePager extends Component {
<div className={styles.recordsContainer}>
<div className={styles.records}>
Total records: {totalRecords}
{translate('TotalRecords', { totalRecords })}
</div>
</div>
</div>

View File

@@ -7,63 +7,63 @@ export const shortcuts = {
OPEN_KEYBOARD_SHORTCUTS_MODAL: {
key: '?',
get name() {
return translate('OpenThisModal');
return translate('KeyboardShortcutsOpenModal');
}
},
CLOSE_MODAL: {
key: 'Esc',
get name() {
return translate('CloseCurrentModal');
return translate('KeyboardShortcutsCloseModal');
}
},
ACCEPT_CONFIRM_MODAL: {
key: 'Enter',
get name() {
return translate('AcceptConfirmationModal');
return translate('KeyboardShortcutsConfirmModal');
}
},
MOVIE_SEARCH_INPUT: {
key: 's',
get name() {
return translate('FocusSearchBox');
return translate('KeyboardShortcutsFocusSearchBox');
}
},
SAVE_SETTINGS: {
key: 'mod+s',
get name() {
return translate('SaveSettings');
return translate('KeyboardShortcutsSaveSettings');
}
},
SCROLL_TOP: {
key: 'mod+home',
get name() {
return translate('MovieIndexScrollTop');
return translate('KeyboardShortcutsMovieIndexScrollTop');
}
},
SCROLL_BOTTOM: {
key: 'mod+end',
get name() {
return translate('MovieIndexScrollBottom');
return translate('KeyboardShortcutsMovieIndexScrollBottom');
}
},
DETAILS_NEXT: {
key: '→',
get name() {
return translate('MovieDetailsNextMovie');
return translate('KeyboardShortcutsMovieDetailsNextMovie');
}
},
DETAILS_PREVIOUS: {
key: '←',
get name() {
return translate('MovieDetailsPreviousMovie');
return translate('KeyboardShortcutsMovieDetailsPreviousMovie');
}
}
};

View File

@@ -1,3 +1,4 @@
import translate from 'Utilities/String/translate';
import * as filterTypes from './filterTypes';
export const ARRAY = 'array';
@@ -20,49 +21,127 @@ export const all = [
export const possibleFilterTypes = {
[ARRAY]: [
{ key: filterTypes.CONTAINS, value: 'contains' },
{ key: filterTypes.NOT_CONTAINS, value: 'does not contain' }
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains')
},
{
key: filterTypes.NOT_CONTAINS,
value: () => translate('FilterDoesNotContain')
}
],
[CONTAINS]: [
{ key: filterTypes.CONTAINS, value: 'contains' }
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains')
}
],
[DATE]: [
{ key: filterTypes.LESS_THAN, value: 'is before' },
{ key: filterTypes.GREATER_THAN, value: 'is after' },
{ key: filterTypes.IN_LAST, value: 'in the last' },
{ key: filterTypes.NOT_IN_LAST, value: 'not in the last' },
{ key: filterTypes.IN_NEXT, value: 'in the next' },
{ key: filterTypes.NOT_IN_NEXT, value: 'not in the next' }
{
key: filterTypes.LESS_THAN,
value: () => translate('FilterIsBefore')
},
{
key: filterTypes.GREATER_THAN,
value: () => translate('FilterIsAfter')
},
{
key: filterTypes.IN_LAST,
value: () => translate('FilterInLast')
},
{
key: filterTypes.NOT_IN_LAST,
value: () => translate('FilterNotInLast')
},
{
key: filterTypes.IN_NEXT,
value: () => translate('FilterInNext')
},
{
key: filterTypes.NOT_IN_NEXT,
value: () => translate('FilterNotInNext')
}
],
[EQUAL]: [
{ key: filterTypes.EQUAL, value: 'is' }
{
key: filterTypes.EQUAL,
value: () => translate('FilterIs')
}
],
[EXACT]: [
{ key: filterTypes.EQUAL, value: 'is' },
{ key: filterTypes.NOT_EQUAL, value: 'is not' }
{
key: filterTypes.EQUAL,
value: () => translate('FilterIs')
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterIsNot')
}
],
[NUMBER]: [
{ key: filterTypes.EQUAL, value: 'equal' },
{ key: filterTypes.GREATER_THAN, value: 'greater than' },
{ key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'greater than or equal' },
{ key: filterTypes.LESS_THAN, value: 'less than' },
{ key: filterTypes.LESS_THAN_OR_EQUAL, value: 'less than or equal' },
{ key: filterTypes.NOT_EQUAL, value: 'not equal' }
{
key: filterTypes.EQUAL,
value: () => translate('FilterEqual')
},
{
key: filterTypes.GREATER_THAN,
value: () => translate('FilterGreaterThan')
},
{
key: filterTypes.GREATER_THAN_OR_EQUAL,
value: () => translate('FilterGreaterThanOrEqual')
},
{
key: filterTypes.LESS_THAN,
value: () => translate('FilterLessThan')
},
{
key: filterTypes.LESS_THAN_OR_EQUAL,
value: () => translate('FilterLessThanOrEqual')
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterNotEqual')
}
],
[STRING]: [
{ key: filterTypes.CONTAINS, value: 'contains' },
{ key: filterTypes.NOT_CONTAINS, value: 'does not contain' },
{ key: filterTypes.EQUAL, value: 'equal' },
{ key: filterTypes.NOT_EQUAL, value: 'not equal' },
{ key: filterTypes.STARTS_WITH, value: 'starts with' },
{ key: filterTypes.NOT_STARTS_WITH, value: 'does not start with' },
{ key: filterTypes.ENDS_WITH, value: 'ends with' },
{ key: filterTypes.NOT_ENDS_WITH, value: 'does not end with' }
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains')
},
{
key: filterTypes.NOT_CONTAINS,
value: () => translate('FilterDoesNotContain')
},
{
key: filterTypes.EQUAL,
value: () => translate('FilterEqual')
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterNotEqual')
},
{
key: filterTypes.STARTS_WITH,
value: () => translate('FilterStartsWith')
},
{
key: filterTypes.NOT_STARTS_WITH,
value: () => translate('FilterDoesNotStartWith')
},
{
key: filterTypes.ENDS_WITH,
value: () => translate('FilterEndsWith')
},
{
key: filterTypes.NOT_ENDS_WITH,
value: () => translate('FilterDoesNotEndWith')
}
]
};

View File

@@ -36,4 +36,5 @@ export type Kind =
| 'primary'
| 'purple'
| 'success'
| 'warning';
| 'warning'
| 'queue';

View File

@@ -192,10 +192,9 @@ const importModeSelector = createSelector(
}
);
interface InteractiveImportModalContentProps {
export interface InteractiveImportModalContentProps {
downloadId?: string;
movieId?: number;
seasonNumber?: number;
showMovie?: boolean;
allowMovieChange?: boolean;
showDelete?: boolean;
@@ -217,7 +216,6 @@ function InteractiveImportModalContent(
const {
downloadId,
movieId,
seasonNumber,
allowMovieChange = true,
showMovie = true,
showFilterExistingFiles = false,
@@ -343,7 +341,6 @@ function InteractiveImportModalContent(
fetchInteractiveImportItems({
downloadId,
movieId,
seasonNumber,
folder,
filterExistingFiles,
})

View File

@@ -24,6 +24,7 @@ import {
reprocessInteractiveImportItems,
updateInteractiveImportItem,
} from 'Store/Actions/interactiveImportActions';
import CustomFormat from 'typings/CustomFormat';
import { SelectStateInputProps } from 'typings/props';
import Rejection from 'typings/Rejection';
import formatBytes from 'Utilities/Number/formatBytes';
@@ -52,7 +53,7 @@ interface InteractiveImportRowProps {
quality?: QualityModel;
languages?: Language[];
size: number;
customFormats?: object[];
customFormats?: CustomFormat[];
customFormatScore?: number;
indexerFlags: number;
rejections: Rejection[];

View File

@@ -2,6 +2,7 @@ import ModelBase from 'App/ModelBase';
import Language from 'Language/Language';
import Movie from 'Movie/Movie';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import Rejection from 'typings/Rejection';
export interface InteractiveImportCommandOptions {
@@ -27,7 +28,7 @@ interface InteractiveImport extends ModelBase {
languages: Language[];
movie?: Movie;
qualityWeight: number;
customFormats: object[];
customFormats: CustomFormat[];
indexerFlags: number;
rejections: Rejection[];
movieFileId?: number;

View File

@@ -4,9 +4,12 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
import { sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import InteractiveImportSelectFolderModalContent from './Folder/InteractiveImportSelectFolderModalContent';
import InteractiveImportModalContent from './Interactive/InteractiveImportModalContent';
import InteractiveImportModalContent, {
InteractiveImportModalContentProps,
} from './Interactive/InteractiveImportModalContent';
interface InteractiveImportModalProps {
interface InteractiveImportModalProps
extends Omit<InteractiveImportModalContentProps, 'modalTitle'> {
isOpen: boolean;
folder?: string;
downloadId?: string;

View File

@@ -65,7 +65,7 @@ interface RowItemData {
}
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
const { items, columns, onMovieSelect } = data;
const { items, onMovieSelect } = data;
const movie = index >= items.length ? null : items[index];
const handlePress = useCallback(() => {
@@ -88,13 +88,11 @@ function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
onPress={handlePress}
>
<SelectMovieRow
id={movie.id}
key={movie.id}
title={movie.title}
tmdbId={movie.tmdbId}
imdbId={movie.imdbId}
year={movie.year}
columns={columns}
onMovieSelect={onMovieSelect}
/>
</VirtualTableRowButton>
);
@@ -181,7 +179,9 @@ function SelectMovieModalContent(props: SelectMovieModalContentProps) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Movie</ModalHeader>
<ModalHeader>
{translate('SelectMovieModalTitle', { modalTitle })}
</ModalHeader>
<ModalBody
className={styles.modalBody}
@@ -189,7 +189,7 @@ function SelectMovieModalContent(props: SelectMovieModalContentProps) {
>
<TextInput
className={styles.filterInput}
placeholder="Filter movies"
placeholder={translate('FilterMoviePlaceholder')}
name="filter"
value={filter}
autoFocus={true}
@@ -225,7 +225,7 @@ function SelectMovieModalContent(props: SelectMovieModalContentProps) {
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
</ModalFooter>
</ModalContent>
);

View File

@@ -1,55 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Label from 'Components/Label';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import styles from './SelectMovieRow.css';
class SelectMovieRow extends Component {
//
// Listeners
onPress = () => {
this.props.onMovieSelect(this.props.id);
};
//
// Render
render() {
return (
<>
<VirtualTableRowCell className={styles.title}>
{this.props.title}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.year}>
{this.props.year}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.imdbId}>
{
this.props.imdbId ?
<Label>{this.props.imdbId}</Label> :
null
}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.tmdbId}>
<Label>{this.props.tmdbId}</Label>
</VirtualTableRowCell>
</>
);
}
}
SelectMovieRow.propTypes = {
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
tmdbId: PropTypes.number.isRequired,
imdbId: PropTypes.string,
year: PropTypes.number.isRequired,
onMovieSelect: PropTypes.func.isRequired
};
export default SelectMovieRow;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import Label from 'Components/Label';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import styles from './SelectMovieRow.css';
interface SelectMovieRowProps {
title: string;
tmdbId: number;
imdbId?: string;
year: number;
}
function SelectMovieRow({ title, year, tmdbId, imdbId }: SelectMovieRowProps) {
return (
<>
<VirtualTableRowCell className={styles.title}>
{title}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.year}>{year}</VirtualTableRowCell>
<VirtualTableRowCell className={styles.imdbId}>
{imdbId ? <Label>{imdbId}</Label> : null}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.tmdbId}>
<Label>{tmdbId}</Label>
</VirtualTableRowCell>
</>
);
}
export default SelectMovieRow;

View File

@@ -1,240 +0,0 @@
import PropTypes from 'prop-types';
import React, { Fragment } from 'react';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageMenuButton from 'Components/Menu/PageMenuButton';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { align, icons, kinds, sortDirections } from 'Helpers/Props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector';
import InteractiveSearchRowConnector from './InteractiveSearchRowConnector';
import styles from './InteractiveSearch.css';
const columns = [
{
name: 'protocol',
label: () => translate('Source'),
isSortable: true,
isVisible: true
},
{
name: 'age',
label: () => translate('Age'),
isSortable: true,
isVisible: true
},
{
name: 'title',
label: () => translate('Title'),
isSortable: true,
isVisible: true
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: true
},
{
name: 'history',
label: () => translate('History'),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: true
},
{
name: 'peers',
label: () => translate('Peers'),
isSortable: true,
isVisible: true
},
{
name: 'languages',
label: () => translate('Language'),
isSortable: true,
isVisible: true
},
{
name: 'qualityWeight',
label: () => translate('Quality'),
isSortable: true,
isVisible: true
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isSortable: true,
isVisible: true
},
{
name: 'indexerFlags',
label: React.createElement(Icon, {
name: icons.FLAG,
title: () => translate('IndexerFlags')
}),
isSortable: true,
isVisible: true
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
title: () => translate('Rejections')
}),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
},
{
name: 'releaseWeight',
label: React.createElement(Icon, { name: icons.DOWNLOAD }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
}
];
function InteractiveSearch(props) {
const {
searchPayload,
isFetching,
isPopulated,
error,
totalReleasesCount,
items,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
longDateFormat,
timeFormat,
onSortPress,
onFilterSelect,
onGrabPress
} = props;
const errorMessage = getErrorMessage(error);
const type = 'movies';
return (
<div>
<div className={styles.filterMenuContainer}>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
buttonComponent={PageMenuButton}
filterModalConnectorComponent={InteractiveSearchFilterModalConnector}
filterModalConnectorComponentProps={{ type }}
onFilterSelect={onFilterSelect}
/>
</div>
{
isFetching ? <LoadingIndicator /> : null
}
{
!isFetching && error ?
<Alert kind={kinds.DANGER} className={styles.alert}>
{
errorMessage ?
<Fragment>
{translate('InteractiveSearchResultsFailedErrorMessage', { message: errorMessage.charAt(0).toLowerCase() + errorMessage.slice(1) })}
</Fragment> :
translate('MovieSearchResultsLoadError')
}
</Alert> :
null
}
{
!isFetching && isPopulated && !totalReleasesCount ?
<Alert kind={kinds.INFO} className={styles.alert}>
{translate('NoResultsFound')}
</Alert> :
null
}
{
!!totalReleasesCount && isPopulated && !items.length ?
<Alert kind={kinds.WARNING} className={styles.alert}>
{translate('AllResultsHiddenFilter')}
</Alert> :
null
}
{
isPopulated && !!items.length ?
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{
items.map((item) => {
return (
<InteractiveSearchRowConnector
key={`${item.indexerId}-${item.guid}`}
{...item}
searchPayload={searchPayload}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
onGrabPress={onGrabPress}
/>
);
})
}
</TableBody>
</Table> :
null
}
{
totalReleasesCount !== items.length && !!items.length ?
<Alert kind={kinds.INFO} className={styles.alert}>
{translate('SomeResultsHiddenFilter')}
</Alert> :
null
}
</div>
);
}
InteractiveSearch.propTypes = {
searchPayload: PropTypes.object.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
totalReleasesCount: PropTypes.number.isRequired,
items: 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,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onSortPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onGrabPress: PropTypes.func.isRequired
};
export default InteractiveSearch;

View File

@@ -0,0 +1,263 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import ReleasesAppState from 'App/State/ReleasesAppState';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageMenuButton from 'Components/Menu/PageMenuButton';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { align, icons, kinds, sortDirections } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { fetchMovieBlocklist } from 'Store/Actions/movieBlocklistActions';
import { fetchMovieHistory } from 'Store/Actions/movieHistoryActions';
import {
fetchReleases,
grabRelease,
setReleasesFilter,
setReleasesSort,
} from 'Store/Actions/releaseActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import InteractiveSearchFilterModal from './InteractiveSearchFilterModal';
import InteractiveSearchPayload from './InteractiveSearchPayload';
import InteractiveSearchRow from './InteractiveSearchRow';
import styles from './InteractiveSearch.css';
const columns: Column[] = [
{
name: 'protocol',
label: () => translate('Source'),
isSortable: true,
isVisible: true,
},
{
name: 'age',
label: () => translate('Age'),
isSortable: true,
isVisible: true,
},
{
name: 'title',
label: () => translate('Title'),
isSortable: true,
isVisible: true,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: true,
},
{
name: 'history',
label: () => translate('History'),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true,
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: true,
},
{
name: 'peers',
label: () => translate('Peers'),
isSortable: true,
isVisible: true,
},
{
name: 'languages',
label: () => translate('Language'),
isSortable: true,
isVisible: true,
},
{
name: 'qualityWeight',
label: () => translate('Quality'),
isSortable: true,
isVisible: true,
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'indexerFlags',
label: React.createElement(Icon, {
name: icons.FLAG,
title: () => translate('IndexerFlags'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
title: () => translate('Rejections'),
}),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true,
},
{
name: 'releaseWeight',
label: React.createElement(Icon, { name: icons.DOWNLOAD }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true,
},
];
interface InteractiveSearchProps {
searchPayload: InteractiveSearchPayload;
}
function InteractiveSearch({ searchPayload }: InteractiveSearchProps) {
const {
isFetching,
isPopulated,
error,
items,
totalItems,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
}: ReleasesAppState & ClientSideCollectionAppState = useSelector(
createClientSideCollectionSelector('releases')
);
const dispatch = useDispatch();
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
dispatch(setReleasesFilter({ selectedFilterKey }));
},
[dispatch]
);
const handleSortPress = useCallback(
(sortKey: string, sortDirection?: SortDirection) => {
dispatch(setReleasesSort({ sortKey, sortDirection }));
},
[dispatch]
);
const handleGrabPress = useCallback(
(payload: object) => {
dispatch(grabRelease(payload));
},
[dispatch]
);
useEffect(
() => {
// Only fetch releases if they are not already being fetched and not yet populated.
if (!isFetching && !isPopulated) {
dispatch(fetchReleases(searchPayload));
const { movieId } = searchPayload;
if (movieId) {
dispatch(fetchMovieBlocklist({ movieId }));
dispatch(fetchMovieHistory({ movieId }));
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const errorMessage = getErrorMessage(error);
return (
<div>
<div className={styles.filterMenuContainer}>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
buttonComponent={PageMenuButton}
filterModalConnectorComponent={InteractiveSearchFilterModal}
filterModalConnectorComponentProps={{ type: 'movies' }}
onFilterSelect={handleFilterSelect}
/>
</div>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER} className={styles.alert}>
{errorMessage ? (
<>
{translate('InteractiveSearchResultsFailedErrorMessage', {
message:
errorMessage.charAt(0).toLowerCase() + errorMessage.slice(1),
})}
</>
) : (
translate('MovieSearchResultsLoadError')
)}
</Alert>
) : null}
{!isFetching && isPopulated && !totalItems ? (
<Alert kind={kinds.INFO} className={styles.alert}>
{translate('NoResultsFound')}
</Alert>
) : null}
{!!totalItems && isPopulated && !items.length ? (
<Alert kind={kinds.WARNING} className={styles.alert}>
{translate('AllResultsHiddenFilter')}
</Alert>
) : null}
{isPopulated && !!items.length ? (
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
return (
<InteractiveSearchRow
key={`${item.indexerId}-${item.guid}`}
{...item}
searchPayload={searchPayload}
onGrabPress={handleGrabPress}
/>
);
})}
</TableBody>
</Table>
) : null}
{totalItems !== items.length && !!items.length ? (
<Alert kind={kinds.INFO} className={styles.alert}>
{translate('SomeResultsHiddenFilter')}
</Alert>
) : null}
</div>
);
}
export default InteractiveSearch;

View File

@@ -1,109 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearMovieHistory, fetchMovieHistory } from 'Store/Actions/movieHistoryActions';
import * as releaseActions from 'Store/Actions/releaseActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import InteractiveSearch from './InteractiveSearch';
function createMapStateToProps(appState) {
return createSelector(
(state) => state.releases.items.length,
createClientSideCollectionSelector('releases'),
createUISettingsSelector(),
(totalReleasesCount, releases, uiSettings) => {
return {
totalReleasesCount,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat,
...releases
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchReleases(payload) {
dispatch(releaseActions.fetchReleases(payload));
},
dispatchFetchMovieHistory({ movieId }) {
dispatch(fetchMovieHistory({ movieId }));
},
dispatchClearMovieHistory() {
dispatch(clearMovieHistory());
},
onSortPress(sortKey, sortDirection) {
dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection }));
},
onFilterSelect(selectedFilterKey) {
dispatch(releaseActions.setReleasesFilter({ selectedFilterKey }));
},
onGrabPress(payload) {
dispatch(releaseActions.grabRelease(payload));
}
};
}
class InteractiveSearchConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
searchPayload,
isPopulated,
dispatchFetchReleases,
dispatchFetchMovieHistory
} = this.props;
// If search results are not yet isPopulated fetch them,
// otherwise re-show the existing props.
if (!isPopulated) {
dispatchFetchReleases(searchPayload);
}
dispatchFetchMovieHistory(searchPayload);
}
componentWillUnmount() {
this.props.dispatchClearMovieHistory();
}
//
// Render
render() {
const {
dispatchFetchReleases,
dispatchFetchMovieHistory,
dispatchClearMovieHistory,
...otherProps
} = this.props;
return (
<InteractiveSearch
{...otherProps}
/>
);
}
}
InteractiveSearchConnector.propTypes = {
searchPayload: PropTypes.object.isRequired,
isPopulated: PropTypes.bool.isRequired,
dispatchFetchReleases: PropTypes.func.isRequired,
dispatchFetchMovieHistory: PropTypes.func.isRequired,
dispatchClearMovieHistory: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector);

View File

@@ -0,0 +1,55 @@
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 { setReleasesFilter } from 'Store/Actions/releaseActions';
function createReleasesSelector() {
return createSelector(
(state: AppState) => state.releases.items,
(releases) => {
return releases;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.releases.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
interface InteractiveSearchFilterModalProps {
isOpen: boolean;
}
export default function InteractiveSearchFilterModal({
...otherProps
}: InteractiveSearchFilterModalProps) {
const sectionItems = useSelector(createReleasesSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setReleasesFilter(payload));
},
[dispatch]
);
return (
<FilterModal
// TODO: Don't spread all the props
{...otherProps}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType="releases"
dispatchSetFilter={dispatchSetFilter}
/>
);
}

View File

@@ -1,29 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterModal from 'Components/Filter/FilterModal';
import { setReleasesFilter } from 'Store/Actions/releaseActions';
function createMapStateToProps() {
return createSelector(
(state) => state.releases.items,
(state) => state.releases.filterBuilderProps,
(sectionItems, filterBuilderProps) => {
return {
sectionItems,
filterBuilderProps,
customFilterType: 'releases'
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchSetFilter(payload) {
const action = setReleasesFilter;
dispatch(action(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal);

View File

@@ -0,0 +1,7 @@
interface MovieSearchPayload {
movieId: number;
}
type InteractiveSearchPayload = MovieSearchPayload;
export default InteractiveSearchPayload;

View File

@@ -1,5 +1,8 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
@@ -8,21 +11,18 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import type DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import MovieFormats from 'Movie/MovieFormats';
import MovieLanguages from 'Movie/MovieLanguages';
import MovieQuality from 'Movie/MovieQuality';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import MovieBlocklist from 'typings/MovieBlocklist';
import MovieHistory from 'typings/MovieHistory';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import Release from 'typings/Release';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import InteractiveSearchPayload from './InteractiveSearchPayload';
import OverrideMatchModal from './OverrideMatch/OverrideMatchModal';
import Peers from './Peers';
import styles from './InteractiveSearchRow.css';
@@ -71,37 +71,42 @@ function getDownloadTooltip(
return translate('AddToDownloadQueue');
}
interface InteractiveSearchRowProps {
guid: string;
protocol: DownloadProtocol;
age: number;
ageHours: number;
ageMinutes: number;
publishDate: string;
title: string;
infoUrl: string;
indexerId: number;
indexer: string;
size: number;
seeders?: number;
leechers?: number;
quality: QualityModel;
languages: Language[];
customFormats: CustomFormat[];
customFormatScore: number;
mappedMovieId?: number;
indexerFlags: string[];
rejections: string[];
downloadAllowed: boolean;
isGrabbing: boolean;
isGrabbed: boolean;
grabError?: string;
historyFailedData?: MovieHistory;
historyGrabbedData?: MovieHistory;
blocklistData?: MovieBlocklist;
longDateFormat: string;
timeFormat: string;
searchPayload: object;
function releaseHistorySelector({ guid }: Release) {
return createSelector(
(state: AppState) => state.movieHistory.items,
(state: AppState) => state.movieBlocklist.items,
(movieHistory, movieBlocklist) => {
let historyFailedData = null;
let blocklistedData = null;
const historyGrabbedData = movieHistory.find(
({ eventType, data }) =>
eventType === 'grabbed' && 'guid' in data && data.guid === guid
);
if (historyGrabbedData) {
historyFailedData = movieHistory.find(
({ eventType, sourceTitle }) =>
eventType === 'downloadFailed' &&
sourceTitle === historyGrabbedData.sourceTitle
);
blocklistedData = movieBlocklist.find(
(item) => item.sourceTitle === historyGrabbedData.sourceTitle
);
}
return {
historyGrabbedData,
historyFailedData,
blocklistedData,
};
}
);
}
interface InteractiveSearchRowProps extends Release {
searchPayload: InteractiveSearchPayload;
onGrabPress(...args: unknown[]): void;
}
@@ -130,16 +135,18 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
downloadAllowed,
isGrabbing = false,
isGrabbed = false,
longDateFormat,
timeFormat,
grabError,
historyGrabbedData = {} as MovieHistory,
historyFailedData = {} as MovieHistory,
blocklistData = {} as MovieBlocklist,
searchPayload,
onGrabPress,
} = props;
const { longDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const { historyGrabbedData, historyFailedData, blocklistedData } =
useSelector(releaseHistorySelector(props));
const [isConfirmGrabModalOpen, setIsConfirmGrabModalOpen] = useState(false);
const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
@@ -211,44 +218,52 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
<TableRowCell className={styles.history}>
{historyGrabbedData?.date && !historyFailedData?.date ? (
<Icon
name={icons.DOWNLOADING}
kind={kinds.DEFAULT}
title={`${translate('Grabbed')}: ${formatDateTime(
historyGrabbedData.date,
longDateFormat,
timeFormat,
{ includeSeconds: true }
)}`}
<Tooltip
anchor={<Icon name={icons.DOWNLOADING} kind={kinds.DEFAULT} />}
tooltip={translate('GrabbedAt', {
date: formatDateTime(
historyGrabbedData.date,
longDateFormat,
timeFormat,
{ includeSeconds: true }
),
})}
kind={kinds.INVERSE}
position={tooltipPositions.LEFT}
/>
) : null}
{historyFailedData?.date ? (
<Icon
name={icons.DOWNLOADING}
kind={kinds.DANGER}
title={`${translate('Failed')}: ${formatDateTime(
historyFailedData.date,
longDateFormat,
timeFormat,
{ includeSeconds: true }
)}`}
<Tooltip
anchor={<Icon name={icons.DOWNLOADING} kind={kinds.DANGER} />}
tooltip={translate('FailedAt', {
date: formatDateTime(
historyFailedData.date,
longDateFormat,
timeFormat,
{ includeSeconds: true }
),
})}
kind={kinds.INVERSE}
position={tooltipPositions.LEFT}
/>
) : null}
{blocklistData?.date ? (
{blocklistedData?.date ? (
<Icon
className={
historyGrabbedData || historyFailedData ? styles.blocklist : ''
}
name={icons.BLOCKLIST}
kind={kinds.DANGER}
title={`${translate('Blocklisted')}: ${formatDateTime(
blocklistData.date,
longDateFormat,
timeFormat,
{ includeSeconds: true }
)}`}
title={translate('BlocklistedAt', {
date: formatDateTime(
blocklistedData.date,
longDateFormat,
timeFormat,
{ includeSeconds: true }
),
})}
/>
) : null}
</TableRowCell>

View File

@@ -1,62 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import InteractiveSearchRow from './InteractiveSearchRow';
function createMapStateToProps() {
return createSelector(
(state, { guid }) => guid,
(state) => state.movieHistory.items,
(state) => state.movieBlocklist.items,
(guid, movieHistory, movieBlocklist) => {
let blocklistData = {};
let historyFailedData = {};
const historyGrabbedData = movieHistory.find((movie) => movie.eventType === 'grabbed' && movie.data.guid === guid);
if (historyGrabbedData) {
historyFailedData = movieHistory.find((movie) => movie.eventType === 'downloadFailed' && movie.sourceTitle === historyGrabbedData.sourceTitle);
blocklistData = movieBlocklist.find((item) => item.sourceTitle === historyGrabbedData.sourceTitle);
}
return {
historyGrabbedData,
historyFailedData,
blocklistData
};
}
);
}
class InteractiveSearchRowConnector extends Component {
//
// Render
render() {
const {
historyGrabbedData,
historyFailedData,
blocklistData,
...otherProps
} = this.props;
return (
<InteractiveSearchRow
historyGrabbedData={historyGrabbedData}
historyFailedData={historyFailedData}
blocklistData={blocklistData}
{...otherProps}
/>
);
}
}
InteractiveSearchRowConnector.propTypes = {
historyGrabbedData: PropTypes.object,
historyFailedData: PropTypes.object,
blocklistData: PropTypes.object
};
export default connect(createMapStateToProps)(InteractiveSearchRowConnector);

View File

@@ -1,9 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
function getKind(seeders) {
function getKind(seeders: number = 0) {
if (seeders > 50) {
return kinds.PRIMARY;
}
@@ -19,7 +18,7 @@ function getKind(seeders) {
return kinds.DANGER;
}
function getPeersTooltipPart(peers, peersUnit) {
function getPeersTooltipPart(peers: number | undefined, peersUnit: string) {
if (peers == null) {
return `Unknown ${peersUnit}s`;
}
@@ -31,27 +30,27 @@ function getPeersTooltipPart(peers, peersUnit) {
return `${peers} ${peersUnit}s`;
}
function Peers(props) {
const {
seeders,
leechers
} = props;
interface PeersProps {
seeders?: number;
leechers?: number;
}
function Peers(props: PeersProps) {
const { seeders, leechers } = props;
const kind = getKind(seeders);
return (
<Label
kind={kind}
title={`${getPeersTooltipPart(seeders, 'seeder')}, ${getPeersTooltipPart(leechers, 'leecher')}`}
title={`${getPeersTooltipPart(seeders, 'seeder')}, ${getPeersTooltipPart(
leechers,
'leecher'
)}`}
>
{seeders == null ? '-' : seeders} / {leechers == null ? '-' : leechers}
</Label>
);
}
Peers.propTypes = {
seeders: PropTypes.number,
leechers: PropTypes.number
};
export default Peers;

View File

@@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import DeleteMovieModalContentConnector from './DeleteMovieModalContentConnector';
function DeleteMovieModal(props) {
const {
isOpen,
onModalClose,
previousMovie,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={onModalClose}
>
<DeleteMovieModalContentConnector
{...otherProps}
onModalClose={onModalClose}
previousMovie={previousMovie}
/>
</Modal>
);
}
DeleteMovieModal.propTypes = {
...DeleteMovieModalContentConnector.propTypes,
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
previousMovie: PropTypes.string
};
export default DeleteMovieModal;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import DeleteMovieModalContent, {
DeleteMovieModalContentProps,
} from './DeleteMovieModalContent';
interface DeleteMovieModalProps extends DeleteMovieModalContentProps {
isOpen: boolean;
}
function DeleteMovieModal({
isOpen,
onModalClose,
...otherProps
}: DeleteMovieModalProps) {
return (
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
<DeleteMovieModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default DeleteMovieModal;

View File

@@ -1,163 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormGroup from 'Components/Form/FormGroup';
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';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './DeleteMovieModalContent.css';
class DeleteMovieModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
deleteFiles: false
};
}
//
// Listeners
onDeleteFilesChange = ({ value }) => {
this.setState({ deleteFiles: value });
};
onDeleteMovieConfirmed = () => {
const deleteFiles = this.state.deleteFiles;
const addImportExclusion = this.props.deleteOptions.addImportExclusion;
this.setState({ deleteFiles: false });
this.props.onDeletePress(deleteFiles, addImportExclusion);
};
//
// Render
render() {
const {
title,
path,
statistics = {},
deleteOptions,
onModalClose,
onDeleteOptionChange
} = this.props;
const {
movieFileCount = 0,
sizeOnDisk = 0
} = statistics;
const deleteFiles = this.state.deleteFiles;
const addImportExclusion = deleteOptions.addImportExclusion;
return (
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
{translate('DeleteHeader', { title })}
</ModalHeader>
<ModalBody>
<div className={styles.pathContainer}>
<Icon
className={styles.pathIcon}
name={icons.FOLDER}
/>
{path}
</div>
<FormGroup>
<FormLabel>
{translate('AddListExclusion')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportExclusion"
value={addImportExclusion}
helpText={translate('AddListExclusionMovieHelpText')}
kind={kinds.DANGER}
onChange={onDeleteOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{movieFileCount === 0 ? translate('DeleteMovieFolder') : translate('DeleteMovieFiles', { movieFileCount })}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={movieFileCount === 0 ? translate('DeleteMovieFolderHelpText') : translate('DeleteMovieFilesHelpText')}
kind={kinds.DANGER}
onChange={this.onDeleteFilesChange}
/>
</FormGroup>
{
deleteFiles ?
<div className={styles.deleteFilesMessage}>
<div><InlineMarkdown data={translate('DeleteMovieFolderConfirmation', { path })} blockClassName={styles.folderPath} /></div>
{
movieFileCount ?
<div className={styles.deleteCount}>
{translate('DeleteMovieFolderMovieCount', { movieFileCount, size: formatBytes(sizeOnDisk) })}
</div> :
null
}
</div> :
null
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onDeleteMovieConfirmed}
>
{translate('Delete')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
DeleteMovieModalContent.propTypes = {
title: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
statistics: PropTypes.object.isRequired,
hasFile: PropTypes.bool.isRequired,
deleteOptions: PropTypes.object.isRequired,
onDeleteOptionChange: PropTypes.func.isRequired,
onDeletePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
DeleteMovieModalContent.defaultProps = {
statistics: {}
};
export default DeleteMovieModalContent;

View File

@@ -0,0 +1,158 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormGroup from 'Components/Form/FormGroup';
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';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import { Statistics } from 'Movie/Movie';
import useMovie from 'Movie/useMovie';
import { deleteMovie, setDeleteOption } from 'Store/Actions/movieActions';
import { CheckInputChanged } from 'typings/inputs';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './DeleteMovieModalContent.css';
export interface DeleteMovieModalContentProps {
movieId: number;
onModalClose: () => void;
}
function DeleteMovieModalContent({
movieId,
onModalClose,
}: DeleteMovieModalContentProps) {
const dispatch = useDispatch();
const {
title,
path,
collection,
statistics = {} as Statistics,
} = useMovie(movieId)!;
const { addImportExclusion } = useSelector(
(state: AppState) => state.movies.deleteOptions
);
const { movieFileCount = 0, sizeOnDisk = 0 } = statistics;
const [deleteFiles, setDeleteFiles] = useState(false);
const handleDeleteFilesChange = useCallback(
({ value }: CheckInputChanged) => {
setDeleteFiles(value);
},
[]
);
const handleDeleteMovieConfirmed = useCallback(() => {
dispatch(
deleteMovie({
id: movieId,
collectionTmdbId: collection?.tmdbId,
deleteFiles,
addImportExclusion,
})
);
onModalClose();
}, [
movieId,
collection,
addImportExclusion,
deleteFiles,
dispatch,
onModalClose,
]);
const handleDeleteOptionChange = useCallback(
({ name, value }: CheckInputChanged) => {
dispatch(setDeleteOption({ [name]: value }));
},
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('DeleteHeader', { title })}</ModalHeader>
<ModalBody>
<div className={styles.pathContainer}>
<Icon className={styles.pathIcon} name={icons.FOLDER} />
{path}
</div>
<FormGroup>
<FormLabel>{translate('AddListExclusion')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportExclusion"
value={addImportExclusion}
helpText={translate('AddListExclusionMovieHelpText')}
kind={kinds.DANGER}
onChange={handleDeleteOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{movieFileCount === 0
? translate('DeleteMovieFolder')
: translate('DeleteMovieFiles', { movieFileCount })}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={
movieFileCount === 0
? translate('DeleteMovieFolderHelpText')
: translate('DeleteMovieFilesHelpText')
}
kind={kinds.DANGER}
onChange={handleDeleteFilesChange}
/>
</FormGroup>
{deleteFiles ? (
<div className={styles.deleteFilesMessage}>
<div>
<InlineMarkdown
data={translate('DeleteMovieFolderConfirmation', { path })}
blockClassName={styles.folderPath}
/>
</div>
{movieFileCount ? (
<div className={styles.deleteCount}>
{translate('DeleteMovieFolderMovieCount', {
movieFileCount,
size: formatBytes(sizeOnDisk),
})}
</div>
) : null}
</div>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
<Button kind={kinds.DANGER} onPress={handleDeleteMovieConfirmed}>
{translate('Delete')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default DeleteMovieModalContent;

View File

@@ -1,45 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteMovie, setDeleteOption } from 'Store/Actions/movieActions';
import createMovieSelector from 'Store/Selectors/createMovieSelector';
import DeleteMovieModalContent from './DeleteMovieModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.movies.deleteOptions,
createMovieSelector(),
(deleteOptions, movie) => {
return {
...movie,
deleteOptions
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onDeleteOptionChange(option) {
dispatch(
setDeleteOption({
[option.name]: option.value
})
);
},
onDeletePress(deleteFiles, addImportExclusion) {
dispatch(
deleteMovie({
id: props.movieId,
collectionTmdbId: this.collection?.tmdbId,
deleteFiles,
addImportExclusion
})
);
props.onModalClose(true);
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteMovieModalContent);

View File

@@ -160,10 +160,10 @@
}
.overview {
flex: 1 0 auto;
flex: 1 0 0;
margin-top: 8px;
padding-left: 7px;
min-height: 0;
text-wrap: balance;
font-size: $intermediateFontSize;
}

View File

@@ -1,832 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import ImdbRating from 'Components/ImdbRating';
import InfoLabel from 'Components/InfoLabel';
import IconButton from 'Components/Link/IconButton';
import Marquee from 'Components/Marquee';
import Measure from 'Components/Measure';
import MonitorToggleButton from 'Components/MonitorToggleButton';
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 RottenTomatoRating from 'Components/RottenTomatoRating';
import TmdbRating from 'Components/TmdbRating';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import TraktRating from 'Components/TraktRating';
import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import MovieHistoryModal from 'Movie/History/MovieHistoryModal';
import MovieCollectionLabelConnector from 'Movie/MovieCollectionLabelConnector';
import MovieGenres from 'Movie/MovieGenres';
import MoviePoster from 'Movie/MoviePoster';
import MovieInteractiveSearchModal from 'Movie/Search/MovieInteractiveSearchModal';
import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable';
import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import fonts from 'Styles/Variables/fonts';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import formatRuntime from 'Utilities/Date/formatRuntime';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import MovieCastPosters from './Credits/Cast/MovieCastPosters';
import MovieCrewPosters from './Credits/Crew/MovieCrewPosters';
import MovieDetailsLinks from './MovieDetailsLinks';
import MovieReleaseDates from './MovieReleaseDates';
import MovieStatusLabel from './MovieStatusLabel';
import MovieTagsConnector from './MovieTagsConnector';
import MovieTitlesTable from './Titles/MovieTitlesTable';
import styles from './MovieDetails.css';
const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function getFanartUrl(images) {
const image = images.find((img) => img.coverType === 'fanart');
return image?.url ?? image?.remoteUrl;
}
class MovieDetails extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isOrganizeModalOpen: false,
isEditMovieModalOpen: false,
isDeleteMovieModalOpen: false,
isInteractiveImportModalOpen: false,
isInteractiveSearchModalOpen: false,
isMovieHistoryModalOpen: false,
overviewHeight: 0,
titleWidth: 0
};
}
componentDidMount() {
window.addEventListener('touchstart', this.onTouchStart);
window.addEventListener('touchend', this.onTouchEnd);
window.addEventListener('touchcancel', this.onTouchCancel);
window.addEventListener('touchmove', this.onTouchMove);
window.addEventListener('keyup', this.onKeyUp);
}
componentWillUnmount() {
window.removeEventListener('touchstart', this.onTouchStart);
window.removeEventListener('touchend', this.onTouchEnd);
window.removeEventListener('touchcancel', this.onTouchCancel);
window.removeEventListener('touchmove', this.onTouchMove);
window.removeEventListener('keyup', this.onKeyUp);
}
//
// Listeners
onOrganizePress = () => {
this.setState({ isOrganizeModalOpen: true });
};
onOrganizeModalClose = () => {
this.setState({ isOrganizeModalOpen: false });
};
onInteractiveImportPress = () => {
this.setState({ isInteractiveImportModalOpen: true });
};
onInteractiveImportModalClose = () => {
this.setState({ isInteractiveImportModalOpen: false });
};
onEditMoviePress = () => {
this.setState({ isEditMovieModalOpen: true });
};
onEditMovieModalClose = () => {
this.setState({ isEditMovieModalOpen: false });
};
onInteractiveSearchPress = () => {
this.setState({ isInteractiveSearchModalOpen: true });
};
onInteractiveSearchModalClose = () => {
this.setState({ isInteractiveSearchModalOpen: false });
};
onDeleteMoviePress = () => {
this.setState({
isEditMovieModalOpen: false,
isDeleteMovieModalOpen: true
});
};
onDeleteMovieModalClose = () => {
this.setState({ isDeleteMovieModalOpen: false });
};
onMovieHistoryPress = () => {
this.setState({ isMovieHistoryModalOpen: true });
};
onMovieHistoryModalClose = () => {
this.setState({ isMovieHistoryModalOpen: false });
};
onMeasure = ({ height }) => {
this.setState({ overviewHeight: height });
};
onTitleMeasure = ({ width }) => {
this.setState({ titleWidth: width });
};
onKeyUp = (event) => {
if (event.composedPath && event.composedPath().length === 4) {
if (event.keyCode === keyCodes.LEFT_ARROW) {
this.props.onGoToMovie(this.props.previousMovie.titleSlug);
}
if (event.keyCode === keyCodes.RIGHT_ARROW) {
this.props.onGoToMovie(this.props.nextMovie.titleSlug);
}
}
};
onTouchStart = (event) => {
const touches = event.touches;
const touchStart = touches[0].pageX;
const touchY = touches[0].pageY;
// Only change when swipe is on header, we need horizontal scroll on tables
if (touchY > 470) {
return;
}
if (touches.length !== 1) {
return;
}
if (
touchStart < 50 ||
this.props.isSidebarVisible ||
this.state.isOrganizeModalOpen ||
this.state.isEditMovieModalOpen ||
this.state.isDeleteMovieModalOpen ||
this.state.isInteractiveImportModalOpen ||
this.state.isInteractiveSearchModalOpen ||
this.state.isMovieHistoryModalOpen
) {
return;
}
this._touchStart = touchStart;
};
onTouchEnd = (event) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!this._touchStart) {
return;
}
if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
this.props.onGoToMovie(this.props.previousMovie.titleSlug);
} else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
this.props.onGoToMovie(this.props.nextMovie.titleSlug);
}
this._touchStart = null;
};
onTouchCancel = (event) => {
this._touchStart = null;
};
onTouchMove = (event) => {
if (!this._touchStart) {
return;
}
};
//
// Render
render() {
const {
id,
tmdbId,
imdbId,
title,
originalTitle,
year,
inCinemas,
physicalRelease,
digitalRelease,
runtime,
certification,
ratings,
path,
statistics,
qualityProfileId,
monitored,
studio,
originalLanguage,
genres,
collection,
overview,
status,
youTubeTrailerId,
isAvailable,
images,
tags,
isSaving,
isRefreshing,
isSearching,
isFetching,
isSmallScreen,
movieFilesError,
movieCreditsError,
extraFilesError,
hasMovieFiles,
previousMovie,
nextMovie,
onMonitorTogglePress,
onRefreshPress,
onSearchPress,
queueItem,
movieRuntimeFormat
} = this.props;
const {
sizeOnDisk = 0
} = statistics;
const {
isOrganizeModalOpen,
isEditMovieModalOpen,
isDeleteMovieModalOpen,
isInteractiveImportModalOpen,
isInteractiveSearchModalOpen,
isMovieHistoryModalOpen,
overviewHeight,
titleWidth
} = this.state;
const statusDetails = getMovieStatusDetails(status);
const fanartUrl = getFanartUrl(images);
const marqueeWidth = isSmallScreen ? titleWidth : (titleWidth - 150);
const titleWithYear = `${title}${year > 0 ? ` (${year})` : ''}`;
return (
<PageContent title={titleWithYear}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RefreshAndScan')}
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
title={translate('RefreshInformationAndScanDisk')}
isSpinning={isRefreshing}
onPress={onRefreshPress}
/>
<PageToolbarButton
label={translate('SearchMovie')}
iconName={icons.SEARCH}
isSpinning={isSearching}
title={undefined}
onPress={onSearchPress}
/>
<PageToolbarButton
label={translate('InteractiveSearch')}
iconName={icons.INTERACTIVE}
isSpinning={isSearching}
title={undefined}
onPress={this.onInteractiveSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('PreviewRename')}
iconName={icons.ORGANIZE}
isDisabled={!hasMovieFiles}
onPress={this.onOrganizePress}
/>
<PageToolbarButton
label={translate('ManageFiles')}
iconName={icons.MOVIE_FILE}
onPress={this.onInteractiveImportPress}
/>
<PageToolbarButton
label={translate('History')}
iconName={icons.HISTORY}
onPress={this.onMovieHistoryPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('Edit')}
iconName={icons.EDIT}
onPress={this.onEditMoviePress}
/>
<PageToolbarButton
label={translate('Delete')}
iconName={icons.DELETE}
onPress={this.onDeleteMoviePress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={
fanartUrl ?
{ backgroundImage: `url(${fanartUrl})` } :
null
}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<MoviePoster
className={styles.poster}
images={images}
size={500}
lazy={false}
/>
<div className={styles.info}>
<Measure onMeasure={this.onTitleMeasure}>
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
size={40}
onPress={onMonitorTogglePress}
/>
</div>
<div className={styles.title} style={{ width: marqueeWidth }}>
<Marquee text={title} title={originalTitle} />
</div>
</div>
<div className={styles.movieNavigationButtons}>
<IconButton
className={styles.movieNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('GoToInterp', [previousMovie.title])}
to={`/movie/${previousMovie.titleSlug}`}
/>
<IconButton
className={styles.movieNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('GoToInterp', [nextMovie.title])}
to={`/movie/${nextMovie.titleSlug}`}
/>
</div>
</div>
</Measure>
<div className={styles.details}>
<div>
{
certification ?
<span className={styles.certification} title={translate('Certification')}>
{certification}
</span> :
null
}
<span className={styles.year}>
<Popover
anchor={
year > 0 ? (
year
) : (
<Icon
name={icons.WARNING}
kind={kinds.WARNING}
size={20}
/>
)
}
title={translate('ReleaseDates')}
body={
<MovieReleaseDates
tmdbId={tmdbId}
inCinemas={inCinemas}
digitalRelease={digitalRelease}
physicalRelease={physicalRelease}
/>
}
position={tooltipPositions.BOTTOM}
/>
</span>
{
runtime ?
<span className={styles.runtime} title={translate('Runtime')}>
{formatRuntime(runtime, movieRuntimeFormat)}
</span> :
null
}
{
<span className={styles.links}>
<Tooltip
anchor={
<Icon
name={icons.EXTERNAL_LINK}
size={20}
/>
}
tooltip={
<MovieDetailsLinks
tmdbId={tmdbId}
imdbId={imdbId}
youTubeTrailerId={youTubeTrailerId}
/>
}
position={tooltipPositions.BOTTOM}
/>
</span>
}
{
!!tags.length &&
<span>
<Tooltip
anchor={
<Icon
name={icons.TAGS}
size={20}
/>
}
tooltip={
<MovieTagsConnector movieId={id} />
}
position={tooltipPositions.BOTTOM}
/>
</span>
}
</div>
</div>
<div className={styles.details}>
{
ratings.tmdb ?
<span className={styles.rating}>
<TmdbRating
ratings={ratings}
iconSize={20}
/>
</span> :
null
}
{
ratings.imdb ?
<span className={styles.rating}>
<ImdbRating
ratings={ratings}
iconSize={20}
/>
</span> :
null
}
{
ratings.rottenTomatoes ?
<span className={styles.rating}>
<RottenTomatoRating
ratings={ratings}
iconSize={20}
/>
</span> :
null
}
{
ratings.trakt ?
<span className={styles.rating}>
<TraktRating
ratings={ratings}
iconSize={20}
/>
</span> :
null
}
</div>
<div className={styles.detailsLabels}>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Path')}
size={sizes.LARGE}
>
<span className={styles.path}>
{path}
</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Status')}
title={statusDetails.message}
kind={kinds.DELETE}
size={sizes.LARGE}
>
<span className={styles.statusName}>
<MovieStatusLabel
status={status}
hasMovieFiles={hasMovieFiles}
monitored={monitored}
isAvailable={isAvailable}
queueItem={queueItem}
/>
</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('QualityProfile')}
size={sizes.LARGE}
>
<span className={styles.qualityProfileName}>
{
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
}
</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Size')}
size={sizes.LARGE}
>
<span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)}
</span>
</InfoLabel>
{
collection ?
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Collection')}
size={sizes.LARGE}
>
<div className={styles.collection}>
<MovieCollectionLabelConnector
tmdbId={collection.tmdbId}
/>
</div>
</InfoLabel> :
null
}
{
originalLanguage?.name && !isSmallScreen ?
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('OriginalLanguage')}
size={sizes.LARGE}
>
<span className={styles.originalLanguage}>
{originalLanguage.name}
</span>
</InfoLabel> :
null
}
{
studio && !isSmallScreen ?
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Studio')}
size={sizes.LARGE}
>
<span className={styles.studio}>
{studio}
</span>
</InfoLabel> :
null
}
{
genres.length && !isSmallScreen ?
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Genres')}
size={sizes.LARGE}
>
<MovieGenres className={styles.genres} genres={genres} />
</InfoLabel> :
null
}
</div>
<Measure onMeasure={this.onMeasure}>
<div className={styles.overview}>
<TextTruncate
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
text={overview}
/>
</div>
</Measure>
</div>
</div>
</div>
<div className={styles.contentContainer}>
{
!isFetching && movieFilesError ?
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieFilesFailed')}
</Alert> :
null
}
{
!isFetching && movieCreditsError ?
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieCreditsFailed')}
</Alert> :
null
}
{
!isFetching && extraFilesError ?
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieExtraFilesFailed')}
</Alert> :
null
}
<FieldSet legend={translate('Files')}>
<MovieFileEditorTable
movieId={id}
/>
<ExtraFileTable
movieId={id}
/>
</FieldSet>
<FieldSet legend={translate('Cast')}>
<MovieCastPosters
isSmallScreen={isSmallScreen}
/>
</FieldSet>
<FieldSet legend={translate('Crew')}>
<MovieCrewPosters
isSmallScreen={isSmallScreen}
/>
</FieldSet>
<FieldSet legend={translate('Titles')}>
<MovieTitlesTable
movieId={id}
/>
</FieldSet>
</div>
<OrganizePreviewModalConnector
isOpen={isOrganizeModalOpen}
movieId={id}
onModalClose={this.onOrganizeModalClose}
/>
<EditMovieModalConnector
isOpen={isEditMovieModalOpen}
movieId={id}
onModalClose={this.onEditMovieModalClose}
onDeleteMoviePress={this.onDeleteMoviePress}
/>
<MovieHistoryModal
isOpen={isMovieHistoryModalOpen}
movieId={id}
onModalClose={this.onMovieHistoryModalClose}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={id}
onModalClose={this.onDeleteMovieModalClose}
nextMovieRelativePath={`/movie/${nextMovie.titleSlug}`}
/>
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
movieId={id}
modalTitle={translate('ManageFiles')}
folder={path}
allowMovieChange={false}
showFilterExistingFiles={true}
showImportMode={false}
onModalClose={this.onInteractiveImportModalClose}
/>
<MovieInteractiveSearchModal
isOpen={isInteractiveSearchModalOpen}
movieId={id}
movieTitle={title}
onModalClose={this.onInteractiveSearchModalClose}
/>
</PageContentBody>
</PageContent>
);
}
}
MovieDetails.propTypes = {
id: PropTypes.number.isRequired,
tmdbId: PropTypes.number.isRequired,
imdbId: PropTypes.string,
title: PropTypes.string.isRequired,
originalTitle: PropTypes.string,
year: PropTypes.number.isRequired,
runtime: PropTypes.number.isRequired,
certification: PropTypes.string,
ratings: PropTypes.object.isRequired,
path: PropTypes.string.isRequired,
statistics: PropTypes.object.isRequired,
qualityProfileId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
studio: PropTypes.string,
originalLanguage: PropTypes.object,
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
collection: PropTypes.object,
youTubeTrailerId: PropTypes.string,
isAvailable: PropTypes.bool.isRequired,
inCinemas: PropTypes.string,
physicalRelease: PropTypes.string,
digitalRelease: PropTypes.string,
overview: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isSaving: PropTypes.bool.isRequired,
isRefreshing: PropTypes.bool.isRequired,
isSearching: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
isSidebarVisible: PropTypes.bool.isRequired,
movieFilesError: PropTypes.object,
movieCreditsError: PropTypes.object,
extraFilesError: PropTypes.object,
hasMovieFiles: PropTypes.bool.isRequired,
previousMovie: PropTypes.object.isRequired,
nextMovie: PropTypes.object.isRequired,
onMonitorTogglePress: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired,
onGoToMovie: PropTypes.func.isRequired,
queueItem: PropTypes.object,
movieRuntimeFormat: PropTypes.string.isRequired
};
MovieDetails.defaultProps = {
genres: [],
statistics: {},
tags: [],
isSaving: false
};
export default MovieDetails;

View File

@@ -0,0 +1,981 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import TextTruncate from 'react-text-truncate';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import ImdbRating from 'Components/ImdbRating';
import InfoLabel from 'Components/InfoLabel';
import IconButton from 'Components/Link/IconButton';
import Marquee from 'Components/Marquee';
import MonitorToggleButton from 'Components/MonitorToggleButton';
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 RottenTomatoRating from 'Components/RottenTomatoRating';
import TmdbRating from 'Components/TmdbRating';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import TraktRating from 'Components/TraktRating';
import useMeasure from 'Helpers/Hooks/useMeasure';
import usePrevious from 'Helpers/Hooks/usePrevious';
import {
icons,
kinds,
sizes,
sortDirections,
tooltipPositions,
} from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import EditMovieModal from 'Movie/Edit/EditMovieModal';
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import MovieHistoryModal from 'Movie/History/MovieHistoryModal';
import { Image, Statistics } from 'Movie/Movie';
import MovieCollectionLabel from 'Movie/MovieCollectionLabel';
import MovieGenres from 'Movie/MovieGenres';
import MoviePoster from 'Movie/MoviePoster';
import MovieInteractiveSearchModal from 'Movie/Search/MovieInteractiveSearchModal';
import useMovie from 'Movie/useMovie';
import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable';
import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable';
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import { executeCommand } from 'Store/Actions/commandActions';
import {
clearExtraFiles,
fetchExtraFiles,
} from 'Store/Actions/extraFileActions';
import { toggleMovieMonitored } from 'Store/Actions/movieActions';
import {
clearMovieCredits,
fetchMovieCredits,
} from 'Store/Actions/movieCreditsActions';
import {
clearMovieFiles,
fetchMovieFiles,
} from 'Store/Actions/movieFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import {
cancelFetchReleases,
clearReleases,
} from 'Store/Actions/releaseActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchImportListSchema } from 'Store/Actions/Settings/importLists';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import fonts from 'Styles/Variables/fonts';
import sortByProp from 'Utilities/Array/sortByProp';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import formatRuntime from 'Utilities/Date/formatRuntime';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import formatBytes from 'Utilities/Number/formatBytes';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import MovieCastPosters from './Credits/Cast/MovieCastPosters';
import MovieCrewPosters from './Credits/Crew/MovieCrewPosters';
import MovieDetailsLinks from './MovieDetailsLinks';
import MovieReleaseDates from './MovieReleaseDates';
import MovieStatusLabel from './MovieStatusLabel';
import MovieTags from './MovieTags';
import MovieTitlesTable from './Titles/MovieTitlesTable';
import styles from './MovieDetails.css';
const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function getFanartUrl(images: Image[]) {
const image = images.find((image) => image.coverType === 'fanart');
return image?.url ?? image?.remoteUrl;
}
function createMovieFilesSelector() {
return createSelector(
(state: AppState) => state.movieFiles,
({ items, isFetching, isPopulated, error }) => {
const hasMovieFiles = !!items.length;
return {
isMovieFilesFetching: isFetching,
isMovieFilesPopulated: isPopulated,
movieFilesError: error,
hasMovieFiles,
};
}
);
}
function createExtraFilesSelector() {
return createSelector(
(state: AppState) => state.extraFiles,
({ isFetching, isPopulated, error }) => {
return {
isExtraFilesFetching: isFetching,
isExtraFilesPopulated: isPopulated,
extraFilesError: error,
};
}
);
}
function createMovieCreditsSelector() {
return createSelector(
(state: AppState) => state.movieCredits,
({ isFetching, isPopulated, error }) => {
return {
isMovieCreditsFetching: isFetching,
isMovieCreditsPopulated: isPopulated,
movieCreditsError: error,
};
}
);
}
interface MovieDetailsProps {
movieId: number;
}
function MovieDetails({ movieId }: MovieDetailsProps) {
const dispatch = useDispatch();
const history = useHistory();
const movie = useMovie(movieId);
const allMovies = useSelector(createAllMoviesSelector());
const { isMovieFilesFetching, movieFilesError, hasMovieFiles } = useSelector(
createMovieFilesSelector()
);
const { isExtraFilesFetching, extraFilesError } = useSelector(
createExtraFilesSelector()
);
const { isMovieCreditsFetching, movieCreditsError } = useSelector(
createMovieCreditsSelector()
);
const { movieRuntimeFormat } = useSelector(createUISettingsSelector());
const isSidebarVisible = useSelector(
(state: AppState) => state.app.isSidebarVisible
);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const commands = useSelector(createCommandsSelector());
const { isRefreshing, isRenaming, isSearching } = useMemo(() => {
const movieRefreshingCommand = findCommand(commands, {
name: commandNames.REFRESH_MOVIE,
});
const isMovieRefreshingCommandExecuting = isCommandExecuting(
movieRefreshingCommand
);
const allMoviesRefreshing =
isMovieRefreshingCommandExecuting &&
!movieRefreshingCommand?.body.movieIds?.length;
const isMovieRefreshing =
isMovieRefreshingCommandExecuting &&
movieRefreshingCommand?.body.movieIds?.includes(movieId);
const isSearchingExecuting = isCommandExecuting(
findCommand(commands, {
name: commandNames.MOVIE_SEARCH,
movieIds: [movieId],
})
);
const isRenamingFiles = isCommandExecuting(
findCommand(commands, {
name: commandNames.RENAME_FILES,
movieId,
})
);
const isRenamingMovieCommand = findCommand(commands, {
name: commandNames.RENAME_MOVIE,
});
const isRenamingMovie =
isCommandExecuting(isRenamingMovieCommand) &&
isRenamingMovieCommand?.body?.movieIds?.includes(movieId);
return {
isRefreshing: isMovieRefreshing || allMoviesRefreshing,
isRenaming: isRenamingFiles || isRenamingMovie,
isSearching: isSearchingExecuting,
};
}, [movieId, commands]);
const { nextMovie, previousMovie } = useMemo(() => {
const sortedMovies = [...allMovies].sort(sortByProp('sortTitle'));
const movieIndex = sortedMovies.findIndex((movie) => movie.id === movieId);
if (movieIndex === -1) {
return {
nextMovie: undefined,
previousMovie: undefined,
};
}
const nextMovie = sortedMovies[movieIndex + 1] ?? sortedMovies[0];
const previousMovie =
sortedMovies[movieIndex - 1] ?? sortedMovies[sortedMovies.length - 1];
return {
nextMovie: {
title: nextMovie.title,
titleSlug: nextMovie.titleSlug,
},
previousMovie: {
title: previousMovie.title,
titleSlug: previousMovie.titleSlug,
},
};
}, [movieId, allMovies]);
const touchStart = useRef<number | null>(null);
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
const [isManageMoviesModalOpen, setIsManageMoviesModalOpen] = useState(false);
const [isInteractiveSearchModalOpen, setIsInteractiveSearchModalOpen] =
useState(false);
const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false);
const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false);
const [isMovieHistoryModalOpen, setIsMovieHistoryModalOpen] = useState(false);
const [titleRef, { width: titleWidth }] = useMeasure();
const [overviewRef, { height: overviewHeight }] = useMeasure();
const wasRefreshing = usePrevious(isRefreshing);
const wasRenaming = usePrevious(isRenaming);
const handleOrganizePress = useCallback(() => {
setIsOrganizeModalOpen(true);
}, []);
const handleOrganizeModalClose = useCallback(() => {
setIsOrganizeModalOpen(false);
}, []);
const handleManageMoviesPress = useCallback(() => {
setIsManageMoviesModalOpen(true);
}, []);
const handleManageMoviesModalClose = useCallback(() => {
setIsManageMoviesModalOpen(false);
}, []);
const handleInteractiveSearchPress = useCallback(() => {
setIsInteractiveSearchModalOpen(true);
}, []);
const handleInteractiveSearchModalClose = useCallback(() => {
setIsInteractiveSearchModalOpen(false);
}, []);
const handleEditMoviePress = useCallback(() => {
setIsEditMovieModalOpen(true);
}, []);
const handleEditMovieModalClose = useCallback(() => {
setIsEditMovieModalOpen(false);
}, []);
const handleDeleteMoviePress = useCallback(() => {
setIsEditMovieModalOpen(false);
setIsDeleteMovieModalOpen(true);
}, []);
const handleDeleteMovieModalClose = useCallback(() => {
setIsDeleteMovieModalOpen(false);
}, []);
const handleMovieHistoryPress = useCallback(() => {
setIsMovieHistoryModalOpen(true);
}, []);
const handleMovieHistoryModalClose = useCallback(() => {
setIsMovieHistoryModalOpen(false);
}, []);
const handleMonitorTogglePress = useCallback(
(value: boolean) => {
dispatch(
toggleMovieMonitored({
movieId,
monitored: value,
})
);
},
[movieId, dispatch]
);
const handleRefreshPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.REFRESH_MOVIE,
movieIds: [movieId],
})
);
}, [movieId, dispatch]);
const handleSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.MOVIE_SEARCH,
movieIds: [movieId],
})
);
}, [movieId, dispatch]);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
const touches = event.touches;
const currentTouch = touches[0].pageX;
const touchY = touches[0].pageY;
// Only change when swipe is on header, we need horizontal scroll on tables
if (touchY > 470) {
return;
}
if (touches.length !== 1) {
return;
}
if (
currentTouch < 50 ||
isSidebarVisible ||
isOrganizeModalOpen ||
isEditMovieModalOpen ||
isDeleteMovieModalOpen ||
isManageMoviesModalOpen ||
isInteractiveSearchModalOpen ||
isMovieHistoryModalOpen
) {
return;
}
touchStart.current = currentTouch;
},
[
isSidebarVisible,
isOrganizeModalOpen,
isEditMovieModalOpen,
isDeleteMovieModalOpen,
isManageMoviesModalOpen,
isInteractiveSearchModalOpen,
isMovieHistoryModalOpen,
]
);
const handleTouchEnd = useCallback(
(event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!touchStart.current) {
return;
}
if (
currentTouch > touchStart.current &&
currentTouch - touchStart.current > 100 &&
previousMovie !== undefined
) {
history.push(getPathWithUrlBase(`/movie/${previousMovie.titleSlug}`));
} else if (
currentTouch < touchStart.current &&
touchStart.current - currentTouch > 100 &&
nextMovie !== undefined
) {
history.push(getPathWithUrlBase(`/movie/${nextMovie.titleSlug}`));
}
touchStart.current = null;
},
[previousMovie, nextMovie, history]
);
const handleTouchCancel = useCallback(() => {
touchStart.current = null;
}, []);
const handleTouchMove = useCallback(() => {
if (!touchStart.current) {
return;
}
}, []);
const handleKeyUp = useCallback(
(event: KeyboardEvent) => {
if (
isOrganizeModalOpen ||
isManageMoviesModalOpen ||
isInteractiveSearchModalOpen ||
isEditMovieModalOpen ||
isDeleteMovieModalOpen ||
isMovieHistoryModalOpen
) {
return;
}
if (event.composedPath && event.composedPath().length === 4) {
if (event.key === 'ArrowLeft' && previousMovie !== undefined) {
history.push(getPathWithUrlBase(`/movie/${previousMovie.titleSlug}`));
}
if (event.key === 'ArrowRight' && nextMovie !== undefined) {
history.push(getPathWithUrlBase(`/movie/${nextMovie.titleSlug}`));
}
}
},
[
isOrganizeModalOpen,
isManageMoviesModalOpen,
isInteractiveSearchModalOpen,
isEditMovieModalOpen,
isDeleteMovieModalOpen,
isMovieHistoryModalOpen,
previousMovie,
nextMovie,
history,
]
);
const populate = useCallback(() => {
dispatch(fetchMovieFiles({ movieId }));
dispatch(fetchExtraFiles({ movieId }));
dispatch(fetchMovieCredits({ movieId }));
dispatch(fetchQueueDetails({ movieId }));
dispatch(fetchImportListSchema());
dispatch(fetchRootFolders());
}, [movieId, dispatch]);
useEffect(() => {
populate();
}, [populate]);
useEffect(() => {
registerPagePopulator(populate, ['movieUpdated']);
return () => {
unregisterPagePopulator(populate);
dispatch(clearMovieFiles());
dispatch(clearExtraFiles());
dispatch(clearMovieCredits());
dispatch(clearQueueDetails());
dispatch(cancelFetchReleases());
dispatch(clearReleases());
};
}, [populate, dispatch]);
useEffect(() => {
if ((!isRefreshing && wasRefreshing) || (!isRenaming && wasRenaming)) {
populate();
}
}, [isRefreshing, wasRefreshing, isRenaming, wasRenaming, populate]);
useEffect(() => {
window.addEventListener('touchstart', handleTouchStart);
window.addEventListener('touchend', handleTouchEnd);
window.addEventListener('touchcancel', handleTouchCancel);
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('touchend', handleTouchEnd);
window.removeEventListener('touchcancel', handleTouchCancel);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('keyup', handleKeyUp);
};
}, [
handleTouchStart,
handleTouchEnd,
handleTouchCancel,
handleTouchMove,
handleKeyUp,
]);
if (!movie) {
return null;
}
const {
id,
tmdbId,
imdbId,
title,
originalTitle,
year,
inCinemas,
physicalRelease,
digitalRelease,
runtime,
certification,
ratings,
path,
statistics = {} as Statistics,
qualityProfileId,
monitored,
studio,
originalLanguage,
genres = [],
collection,
overview,
status,
youTubeTrailerId,
isAvailable,
images,
tags,
isSaving = false,
} = movie;
const { sizeOnDisk = 0 } = statistics;
const statusDetails = getMovieStatusDetails(status);
const fanartUrl = getFanartUrl(images);
const isFetching =
isMovieFilesFetching || isExtraFilesFetching || isMovieCreditsFetching;
const marqueeWidth = isSmallScreen ? titleWidth : titleWidth - 150;
const titleWithYear = `${title}${year > 0 ? ` (${year})` : ''}`;
return (
<PageContent title={titleWithYear}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RefreshAndScan')}
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
title={translate('RefreshInformationAndScanDisk')}
isSpinning={isRefreshing}
onPress={handleRefreshPress}
/>
<PageToolbarButton
label={translate('SearchMovie')}
iconName={icons.SEARCH}
isSpinning={isSearching}
title={undefined}
onPress={handleSearchPress}
/>
<PageToolbarButton
label={translate('InteractiveSearch')}
iconName={icons.INTERACTIVE}
isSpinning={isSearching}
title={undefined}
onPress={handleInteractiveSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('PreviewRename')}
iconName={icons.ORGANIZE}
isDisabled={!hasMovieFiles}
onPress={handleOrganizePress}
/>
<PageToolbarButton
label={translate('ManageFiles')}
iconName={icons.MOVIE_FILE}
onPress={handleManageMoviesPress}
/>
<PageToolbarButton
label={translate('History')}
iconName={icons.HISTORY}
onPress={handleMovieHistoryPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('Edit')}
iconName={icons.EDIT}
onPress={handleEditMoviePress}
/>
<PageToolbarButton
label={translate('Delete')}
iconName={icons.DELETE}
onPress={handleDeleteMoviePress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<MoviePoster
className={styles.poster}
images={images}
size={500}
lazy={false}
/>
<div className={styles.info}>
<div ref={titleRef} className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
size={40}
onPress={handleMonitorTogglePress}
/>
</div>
<div className={styles.title} style={{ width: marqueeWidth }}>
<Marquee text={title} title={originalTitle} />
</div>
</div>
<div className={styles.movieNavigationButtons}>
{previousMovie ? (
<IconButton
className={styles.movieNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('MovieDetailsGoTo', {
title: previousMovie.title,
})}
to={`/movie/${previousMovie.titleSlug}`}
/>
) : null}
{nextMovie ? (
<IconButton
className={styles.movieNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('MovieDetailsGoTo', {
title: nextMovie.title,
})}
to={`/movie/${nextMovie.titleSlug}`}
/>
) : null}
</div>
</div>
<div className={styles.details}>
<div>
{certification ? (
<span
className={styles.certification}
title={translate('Certification')}
>
{certification}
</span>
) : null}
<span className={styles.year}>
<Popover
anchor={
year > 0 ? (
year
) : (
<Icon
name={icons.WARNING}
kind={kinds.WARNING}
size={20}
/>
)
}
title={translate('ReleaseDates')}
body={
<MovieReleaseDates
tmdbId={tmdbId}
inCinemas={inCinemas}
digitalRelease={digitalRelease}
physicalRelease={physicalRelease}
/>
}
position={tooltipPositions.BOTTOM}
/>
</span>
{runtime ? (
<span
className={styles.runtime}
title={translate('Runtime')}
>
{formatRuntime(runtime, movieRuntimeFormat)}
</span>
) : null}
<span className={styles.links}>
<Tooltip
anchor={<Icon name={icons.EXTERNAL_LINK} size={20} />}
tooltip={
<MovieDetailsLinks
tmdbId={tmdbId}
imdbId={imdbId}
youTubeTrailerId={youTubeTrailerId}
/>
}
position={tooltipPositions.BOTTOM}
/>
</span>
{!!tags.length && (
<span>
<Tooltip
anchor={<Icon name={icons.TAGS} size={20} />}
tooltip={<MovieTags movieId={id} />}
position={tooltipPositions.BOTTOM}
/>
</span>
)}
</div>
</div>
<div className={styles.details}>
{ratings.tmdb ? (
<span className={styles.rating}>
<TmdbRating ratings={ratings} iconSize={20} />
</span>
) : null}
{ratings.imdb ? (
<span className={styles.rating}>
<ImdbRating ratings={ratings} iconSize={20} />
</span>
) : null}
{ratings.rottenTomatoes ? (
<span className={styles.rating}>
<RottenTomatoRating ratings={ratings} iconSize={20} />
</span>
) : null}
{ratings.trakt ? (
<span className={styles.rating}>
<TraktRating ratings={ratings} iconSize={20} />
</span>
) : null}
</div>
<div>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Path')}
size={sizes.LARGE}
>
<span className={styles.path}>{path}</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Status')}
title={statusDetails.message}
size={sizes.LARGE}
>
<span className={styles.statusName}>
<MovieStatusLabel
movieId={id}
monitored={monitored}
isAvailable={isAvailable}
hasMovieFiles={hasMovieFiles}
status={status}
/>
</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('QualityProfile')}
size={sizes.LARGE}
>
<span className={styles.qualityProfileName}>
<QualityProfileName qualityProfileId={qualityProfileId} />
</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Size')}
size={sizes.LARGE}
>
<span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)}
</span>
</InfoLabel>
{collection ? (
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Collection')}
size={sizes.LARGE}
>
<div className={styles.collection}>
<MovieCollectionLabel tmdbId={collection.tmdbId} />
</div>
</InfoLabel>
) : null}
{originalLanguage?.name && !isSmallScreen ? (
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('OriginalLanguage')}
size={sizes.LARGE}
>
<span className={styles.originalLanguage}>
{originalLanguage.name}
</span>
</InfoLabel>
) : null}
{studio && !isSmallScreen ? (
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Studio')}
size={sizes.LARGE}
>
<span className={styles.studio}>{studio}</span>
</InfoLabel>
) : null}
{genres.length && !isSmallScreen ? (
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Genres')}
size={sizes.LARGE}
>
<MovieGenres className={styles.genres} genres={genres} />
</InfoLabel>
) : null}
</div>
<div ref={overviewRef} className={styles.overview}>
<TextTruncate
line={Math.floor(
overviewHeight / (defaultFontSize * lineHeight)
)}
text={overview}
/>
</div>
</div>
</div>
</div>
<div className={styles.contentContainer}>
{!isFetching && movieFilesError ? (
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieFilesFailed')}
</Alert>
) : null}
{!isFetching && extraFilesError ? (
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieExtraFilesFailed')}
</Alert>
) : null}
{!isFetching && movieCreditsError ? (
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieCreditsFailed')}
</Alert>
) : null}
<FieldSet legend={translate('Files')}>
<MovieFileEditorTable movieId={id} />
<ExtraFileTable movieId={id} />
</FieldSet>
<FieldSet legend={translate('Cast')}>
<MovieCastPosters isSmallScreen={isSmallScreen} />
</FieldSet>
<FieldSet legend={translate('Crew')}>
<MovieCrewPosters isSmallScreen={isSmallScreen} />
</FieldSet>
<FieldSet legend={translate('Titles')}>
<MovieTitlesTable movieId={id} />
</FieldSet>
</div>
<OrganizePreviewModal
isOpen={isOrganizeModalOpen}
movieId={id}
onModalClose={handleOrganizeModalClose}
/>
<EditMovieModal
isOpen={isEditMovieModalOpen}
movieId={id}
onModalClose={handleEditMovieModalClose}
onDeleteMoviePress={handleDeleteMoviePress}
/>
<MovieHistoryModal
isOpen={isMovieHistoryModalOpen}
movieId={id}
onModalClose={handleMovieHistoryModalClose}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={id}
onModalClose={handleDeleteMovieModalClose}
/>
<InteractiveImportModal
isOpen={isManageMoviesModalOpen}
movieId={id}
title={title}
folder={path}
initialSortKey="relativePath"
initialSortDirection={sortDirections.ASCENDING}
showMovie={false}
allowMovieChange={false}
showDelete={true}
showImportMode={false}
modalTitle={translate('ManageFiles')}
onModalClose={handleManageMoviesModalClose}
/>
<MovieInteractiveSearchModal
isOpen={isInteractiveSearchModalOpen}
movieId={id}
movieTitle={title}
onModalClose={handleInteractiveSearchModalClose}
/>
</PageContentBody>
</PageContent>
);
}
export default MovieDetails;

View File

@@ -1,356 +0,0 @@
import { push } from 'connected-react-router';
import _ from 'lodash';
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 { executeCommand } from 'Store/Actions/commandActions';
import { clearExtraFiles, fetchExtraFiles } from 'Store/Actions/extraFileActions';
import { toggleMovieMonitored } from 'Store/Actions/movieActions';
import { clearMovieBlocklist, fetchMovieBlocklist } from 'Store/Actions/movieBlocklistActions';
import { clearMovieCredits, fetchMovieCredits } from 'Store/Actions/movieCreditsActions';
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import { fetchImportListSchema } from 'Store/Actions/settingsActions';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import MovieDetails from './MovieDetails';
const selectMovieFiles = createSelector(
(state) => state.movieFiles,
(movieFiles) => {
const {
items,
isFetching,
isPopulated,
error
} = movieFiles;
const hasMovieFiles = !!items.length;
return {
isMovieFilesFetching: isFetching,
isMovieFilesPopulated: isPopulated,
movieFilesError: error,
hasMovieFiles
};
}
);
const selectMovieCredits = createSelector(
(state) => state.movieCredits,
(movieCredits) => {
const {
isFetching,
isPopulated,
error
} = movieCredits;
return {
isMovieCreditsFetching: isFetching,
isMovieCreditsPopulated: isPopulated,
movieCreditsError: error
};
}
);
const selectExtraFiles = createSelector(
(state) => state.extraFiles,
(extraFiles) => {
const {
isFetching,
isPopulated,
error
} = extraFiles;
return {
isExtraFilesFetching: isFetching,
isExtraFilesPopulated: isPopulated,
extraFilesError: error
};
}
);
function createMapStateToProps() {
return createSelector(
(state, { titleSlug }) => titleSlug,
selectMovieFiles,
selectMovieCredits,
selectExtraFiles,
createAllMoviesSelector(),
createCommandsSelector(),
createDimensionsSelector(),
(state) => state.queue.details.items,
(state) => state.app.isSidebarVisible,
(state) => state.settings.ui.item.movieRuntimeFormat,
(titleSlug, movieFiles, movieCredits, extraFiles, allMovies, commands, dimensions, queueItems, isSidebarVisible, movieRuntimeFormat) => {
const sortedMovies = _.orderBy(allMovies, 'sortTitle');
const movieIndex = _.findIndex(sortedMovies, { titleSlug });
const movie = sortedMovies[movieIndex];
if (!movie) {
return {};
}
const {
isMovieFilesFetching,
isMovieFilesPopulated,
movieFilesError,
hasMovieFiles
} = movieFiles;
const {
isMovieCreditsFetching,
isMovieCreditsPopulated,
movieCreditsError
} = movieCredits;
const {
isExtraFilesFetching,
isExtraFilesPopulated,
extraFilesError
} = extraFiles;
const previousMovie = sortedMovies[movieIndex - 1] || _.last(sortedMovies);
const nextMovie = sortedMovies[movieIndex + 1] || _.first(sortedMovies);
const isMovieRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_MOVIE, movieIds: [movie.id] }));
const movieRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_MOVIE });
const allMoviesRefreshing = (
isCommandExecuting(movieRefreshingCommand) &&
!movieRefreshingCommand.body.movieId
);
const isRefreshing = isMovieRefreshing || allMoviesRefreshing;
const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.MOVIE_SEARCH, movieIds: [movie.id] }));
const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, movieId: movie.id }));
const isRenamingMovieCommand = findCommand(commands, { name: commandNames.RENAME_MOVIE });
const isRenamingMovie = (
isCommandExecuting(isRenamingMovieCommand) &&
isRenamingMovieCommand.body.movieIds.indexOf(movie.id) > -1
);
const isFetching = isMovieFilesFetching || isMovieCreditsFetching || isExtraFilesFetching;
const isPopulated = isMovieFilesPopulated && isMovieCreditsPopulated && isExtraFilesPopulated;
const alternateTitles = _.reduce(movie.alternateTitles, (acc, alternateTitle) => {
acc.push(alternateTitle.title);
return acc;
}, []);
const queueItem = queueItems.find((item) => item.movieId === movie.id);
return {
...movie,
alternateTitles,
isMovieRefreshing,
allMoviesRefreshing,
isRefreshing,
isSearching,
isRenamingFiles,
isRenamingMovie,
isFetching,
isPopulated,
movieFilesError,
movieCreditsError,
extraFilesError,
hasMovieFiles,
previousMovie,
nextMovie,
isSmallScreen: dimensions.isSmallScreen,
isSidebarVisible,
queueItem,
movieRuntimeFormat
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchMovieFiles({ movieId }) {
dispatch(fetchMovieFiles({ movieId }));
},
dispatchClearMovieFiles() {
dispatch(clearMovieFiles());
},
dispatchFetchMovieCredits({ movieId }) {
dispatch(fetchMovieCredits({ movieId }));
},
dispatchClearMovieCredits() {
dispatch(clearMovieCredits());
},
dispatchFetchExtraFiles({ movieId }) {
dispatch(fetchExtraFiles({ movieId }));
},
dispatchClearExtraFiles() {
dispatch(clearExtraFiles());
},
dispatchClearReleases() {
dispatch(clearReleases());
},
dispatchCancelFetchReleases() {
dispatch(cancelFetchReleases());
},
dispatchFetchQueueDetails({ movieId }) {
dispatch(fetchQueueDetails({ movieId }));
},
dispatchClearQueueDetails() {
dispatch(clearQueueDetails());
},
dispatchFetchImportListSchema() {
dispatch(fetchImportListSchema());
},
dispatchToggleMovieMonitored(payload) {
dispatch(toggleMovieMonitored(payload));
},
dispatchExecuteCommand(payload) {
dispatch(executeCommand(payload));
},
onGoToMovie(titleSlug) {
dispatch(push(`${window.Radarr.urlBase}/movie/${titleSlug}`));
},
dispatchFetchMovieBlocklist({ movieId }) {
dispatch(fetchMovieBlocklist({ movieId }));
},
dispatchClearMovieBlocklist() {
dispatch(clearMovieBlocklist());
}
};
}
class MovieDetailsConnector extends Component {
//
// Lifecycle
componentDidMount() {
registerPagePopulator(this.populate, ['movieUpdated']);
this.populate();
}
componentDidUpdate(prevProps) {
const {
id,
isMovieRefreshing,
allMoviesRefreshing,
isRenamingFiles,
isRenamingMovie
} = this.props;
if (
(prevProps.isMovieRefreshing && !isMovieRefreshing) ||
(prevProps.allMoviesRefreshing && !allMoviesRefreshing) ||
(prevProps.isRenamingFiles && !isRenamingFiles) ||
(prevProps.isRenamingMovie && !isRenamingMovie)
) {
this.populate();
}
// If the id has changed we need to clear the episodes/episode
// files and fetch from the server.
if (prevProps.id !== id) {
this.unpopulate();
this.populate();
}
}
componentWillUnmount() {
unregisterPagePopulator(this.populate);
this.unpopulate();
}
//
// Control
populate = () => {
const movieId = this.props.id;
this.props.dispatchFetchMovieFiles({ movieId });
this.props.dispatchFetchMovieBlocklist({ movieId });
this.props.dispatchFetchExtraFiles({ movieId });
this.props.dispatchFetchMovieCredits({ movieId });
this.props.dispatchFetchQueueDetails({ movieId });
this.props.dispatchFetchImportListSchema();
};
unpopulate = () => {
this.props.dispatchCancelFetchReleases();
this.props.dispatchClearMovieBlocklist();
this.props.dispatchClearMovieFiles();
this.props.dispatchClearExtraFiles();
this.props.dispatchClearMovieCredits();
this.props.dispatchClearQueueDetails();
this.props.dispatchClearReleases();
};
//
// Listeners
onMonitorTogglePress = (monitored) => {
this.props.dispatchToggleMovieMonitored({
movieId: this.props.id,
monitored
});
};
onRefreshPress = () => {
this.props.dispatchExecuteCommand({
name: commandNames.REFRESH_MOVIE,
movieIds: [this.props.id]
});
};
onSearchPress = () => {
this.props.dispatchExecuteCommand({
name: commandNames.MOVIE_SEARCH,
movieIds: [this.props.id]
});
};
//
// Render
render() {
return (
<MovieDetails
{...this.props}
onMonitorTogglePress={this.onMonitorTogglePress}
onRefreshPress={this.onRefreshPress}
onSearchPress={this.onSearchPress}
/>
);
}
}
MovieDetailsConnector.propTypes = {
id: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
isMovieRefreshing: PropTypes.bool.isRequired,
allMoviesRefreshing: PropTypes.bool.isRequired,
isRefreshing: PropTypes.bool.isRequired,
isRenamingFiles: PropTypes.bool.isRequired,
isRenamingMovie: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
dispatchFetchMovieFiles: PropTypes.func.isRequired,
dispatchClearMovieFiles: PropTypes.func.isRequired,
dispatchFetchExtraFiles: PropTypes.func.isRequired,
dispatchClearExtraFiles: PropTypes.func.isRequired,
dispatchFetchMovieCredits: PropTypes.func.isRequired,
dispatchClearMovieCredits: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired,
dispatchToggleMovieMonitored: PropTypes.func.isRequired,
dispatchFetchQueueDetails: PropTypes.func.isRequired,
dispatchClearQueueDetails: PropTypes.func.isRequired,
dispatchFetchImportListSchema: PropTypes.func.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired,
dispatchFetchMovieBlocklist: PropTypes.func.isRequired,
dispatchClearMovieBlocklist: PropTypes.func.isRequired,
onGoToMovie: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(MovieDetailsConnector);

View File

@@ -92,6 +92,19 @@ function MovieDetailsLinks(props: MovieDetailsLinksProps) {
MDBList
</Label>
</Link>
<Link
className={styles.link}
to={`https://www.blu-ray.com/search/?quicksearch=1&quicksearch_keyword=${imdbId}&section=theatrical`}
>
<Label
className={styles.linkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
Blu-ray
</Label>
</Link>
</>
) : null}

View File

@@ -0,0 +1,39 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { useHistory } from 'react-router-dom';
import NotFound from 'Components/NotFound';
import usePrevious from 'Helpers/Hooks/usePrevious';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import translate from 'Utilities/String/translate';
import MovieDetails from './MovieDetails';
function MovieDetailsPage() {
const allMovies = useSelector(createAllMoviesSelector());
const { titleSlug } = useParams<{ titleSlug: string }>();
const history = useHistory();
const movieIndex = allMovies.findIndex(
(movie) => movie.titleSlug === titleSlug
);
const previousIndex = usePrevious(movieIndex);
useEffect(() => {
if (
movieIndex === -1 &&
previousIndex !== -1 &&
previousIndex !== undefined
) {
history.push(`${window.Radarr.urlBase}/`);
}
}, [movieIndex, previousIndex, history]);
if (movieIndex === -1) {
return <NotFound message={translate('MovieCannotBeFound')} />;
}
return <MovieDetails movieId={allMovies[movieIndex].id} />;
}
export default MovieDetailsPage;

View File

@@ -1,125 +0,0 @@
import { push } from 'connected-react-router';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import NotFound from 'Components/NotFound';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import MovieDetailsConnector from './MovieDetailsConnector';
import styles from './MovieDetails.css';
function createMapStateToProps() {
return createSelector(
(state, { match }) => match,
(state) => state.movies,
(match, movies) => {
const titleSlug = match.params.titleSlug;
const {
isFetching,
isPopulated,
error,
items
} = movies;
const movieIndex = _.findIndex(items, { titleSlug });
if (movieIndex > -1) {
return {
isFetching,
isPopulated,
titleSlug
};
}
return {
isFetching,
isPopulated,
error
};
}
);
}
const mapDispatchToProps = {
push,
fetchRootFolders
};
class MovieDetailsPageConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchRootFolders();
}
componentDidUpdate(prevProps) {
if (!this.props.titleSlug) {
this.props.push(`${window.Radarr.urlBase}/`);
return;
}
}
//
// Render
render() {
const {
titleSlug,
isFetching,
isPopulated,
error
} = this.props;
if (isFetching && !isPopulated) {
return (
<PageContent title={translate('Loading')}>
<PageContentBody>
<LoadingIndicator />
</PageContentBody>
</PageContent>
);
}
if (!isFetching && !!error) {
return (
<div className={styles.errorMessage}>
{getErrorMessage(error, translate('FailedToLoadMovieFromAPI'))}
</div>
);
}
if (!titleSlug) {
return (
<NotFound
message={translate('SorryThatMovieCannotBeFound')}
/>
);
}
return (
<MovieDetailsConnector
titleSlug={titleSlug}
/>
);
}
}
MovieDetailsPageConnector.propTypes = {
titleSlug: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired,
push: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MovieDetailsPageConnector);

View File

@@ -1,13 +1,23 @@
import PropTypes from 'prop-types';
import React from 'react';
import { useSelector } from 'react-redux';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { MovieStatus } from 'Movie/Movie';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import Queue from 'typings/Queue';
import getQueueStatusText from 'Utilities/Movie/getQueueStatusText';
import firstCharToUpper from 'Utilities/String/firstCharToUpper';
import translate from 'Utilities/String/translate';
import styles from './MovieStatusLabel.css';
function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = false) {
function getMovieStatus(
status: MovieStatus,
isMonitored: boolean,
isAvailable: boolean,
hasFiles: boolean,
queueItem: Queue | null = null
) {
if (queueItem) {
const queueStatus = queueItem.status;
const queueState = queueItem.trackedDownloadStatus;
@@ -18,11 +28,11 @@ function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = f
}
}
if (hasFile && !isMonitored) {
if (hasFiles && !isMonitored) {
return 'availNotMonitored';
}
if (hasFile) {
if (hasFiles) {
return 'ended';
}
@@ -30,34 +40,52 @@ function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = f
return 'deleted';
}
if (isAvailable && !isMonitored && !hasFile) {
if (isAvailable && !isMonitored && !hasFiles) {
return 'missingUnmonitored';
}
if (isAvailable && !hasFile) {
if (isAvailable && !hasFiles) {
return 'missingMonitored';
}
return 'continuing';
}
function MovieStatusLabel(props) {
const {
interface MovieStatusLabelProps {
movieId: number;
monitored: boolean;
isAvailable: boolean;
hasMovieFiles: boolean;
status: MovieStatus;
useLabel?: boolean;
}
function MovieStatusLabel({
movieId,
monitored,
isAvailable,
hasMovieFiles,
status,
useLabel = false,
}: MovieStatusLabelProps) {
const queueItem = useSelector(createQueueItemSelectorForHook(movieId));
let movieStatus = getMovieStatus(
status,
hasMovieFiles,
monitored,
isAvailable,
queueItem,
useLabel,
colorImpairedMode
} = props;
hasMovieFiles,
queueItem
);
let movieStatus = getMovieStatus(status, hasMovieFiles, monitored, isAvailable, queueItem);
let statusClass = movieStatus;
if (movieStatus === 'availNotMonitored' || movieStatus === 'ended') {
movieStatus = 'downloaded';
} else if (movieStatus === 'missingMonitored' || movieStatus === 'missingUnmonitored') {
} else if (
movieStatus === 'missingMonitored' ||
movieStatus === 'missingUnmonitored'
) {
movieStatus = 'missing';
} else if (movieStatus === 'continuing') {
movieStatus = 'notAvailable';
@@ -68,7 +96,7 @@ function MovieStatusLabel(props) {
}
if (useLabel) {
let kind = kinds.SUCCESS;
let kind: Kind = kinds.SUCCESS;
switch (statusClass) {
case 'queue':
@@ -93,11 +121,7 @@ function MovieStatusLabel(props) {
}
return (
<Label
kind={kind}
size={sizes.LARGE}
colorImpairedMode={colorImpairedMode}
>
<Label kind={kind} size={sizes.LARGE}>
{translate(firstCharToUpper(movieStatus))}
</Label>
);
@@ -105,6 +129,8 @@ function MovieStatusLabel(props) {
return (
<span
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
className={styles[statusClass]}
>
{translate(firstCharToUpper(movieStatus))}
@@ -112,19 +138,4 @@ function MovieStatusLabel(props) {
);
}
MovieStatusLabel.propTypes = {
status: PropTypes.string.isRequired,
hasMovieFiles: PropTypes.bool.isRequired,
monitored: PropTypes.bool.isRequired,
isAvailable: PropTypes.bool.isRequired,
queueItem: PropTypes.object,
useLabel: PropTypes.bool,
colorImpairedMode: PropTypes.bool
};
MovieStatusLabel.defaultProps = {
useLabel: false,
colorImpairedMode: false
};
export default MovieStatusLabel;

View File

@@ -1,30 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
function MovieTags({ tags }) {
return (
<div>
{
tags.map((tag) => {
return (
<Label
key={tag}
kind={kinds.INFO}
size={sizes.LARGE}
>
{tag}
</Label>
);
})
}
</div>
);
}
MovieTags.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default MovieTags;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import useMovie from 'Movie/useMovie';
import useTags from 'Tags/useTags';
import sortByProp from 'Utilities/Array/sortByProp';
interface MovieTagsProps {
movieId: number;
}
function MovieTags({ movieId }: MovieTagsProps) {
const movie = useMovie(movieId)!;
const tagList = useTags();
const tags = movie.tags
.map((tagId) => tagList.find((tag) => tag.id === tagId))
.filter((tag) => !!tag)
.sort(sortByProp('label'))
.map((tag) => tag.label);
return (
<div>
{tags.map((tag) => {
return (
<Label key={tag} kind={kinds.INFO} size={sizes.LARGE}>
{tag}
</Label>
);
})}
</div>
);
}
export default MovieTags;

View File

@@ -1,26 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createMovieSelector from 'Store/Selectors/createMovieSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import MovieTags from './MovieTags';
function createMapStateToProps() {
return createSelector(
createMovieSelector(),
createTagsSelector(),
(movie, tagList) => {
const tags = movie.tags
.map((tagId) => tagList.find((tag) => tag.id === tagId))
.filter((tag) => !!tag)
.sort(sortByProp('label'))
.map((tag) => tag.label);
return {
tags
};
}
);
}
export default connect(createMapStateToProps)(MovieTags);

View File

@@ -1,26 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import EditMovieModalContentConnector from './EditMovieModalContentConnector';
function EditMovieModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditMovieModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditMovieModal.propTypes = {
...EditMovieModalContentConnector.propTypes,
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditMovieModal;

View File

@@ -0,0 +1,32 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditMovieModalContent, {
EditMovieModalContentProps,
} from './EditMovieModalContent';
interface EditMovieModalProps extends EditMovieModalContentProps {
isOpen: boolean;
}
function EditMovieModal({
isOpen,
onModalClose,
...otherProps
}: EditMovieModalProps) {
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'movies' }));
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
<EditMovieModalContent {...otherProps} onModalClose={handleModalClose} />
</Modal>
);
}
export default EditMovieModal;

View File

@@ -1,40 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditMovieModal from './EditMovieModal';
const mapDispatchToProps = {
clearPendingChanges
};
class EditMovieModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'movies' });
this.props.onModalClose();
};
//
// Render
render() {
return (
<EditMovieModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditMovieModalConnector.propTypes = {
...EditMovieModal.propTypes,
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(undefined, mapDispatchToProps)(EditMovieModalConnector);

View File

@@ -1,217 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MovieMinimumAvailabilityPopoverContent from 'AddMovie/MovieMinimumAvailabilityPopoverContent';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import MoveMovieModal from 'Movie/MoveMovie/MoveMovieModal';
import translate from 'Utilities/String/translate';
import styles from './EditMovieModalContent.css';
class EditMovieModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isConfirmMoveModalOpen: false
};
}
//
// Listeners
onCancelPress = () => {
this.setState({ isConfirmMoveModalOpen: false });
};
onSavePress = () => {
const {
isPathChanging,
onSavePress
} = this.props;
if (isPathChanging && !this.state.isConfirmMoveModalOpen) {
this.setState({ isConfirmMoveModalOpen: true });
} else {
this.setState({ isConfirmMoveModalOpen: false });
onSavePress(false);
}
};
onMoveMoviePress = () => {
this.setState({ isConfirmMoveModalOpen: false });
this.props.onSavePress(true);
};
//
// Render
render() {
const {
title,
item,
isSaving,
originalPath,
onInputChange,
onModalClose,
onDeleteMoviePress,
...otherProps
} = this.props;
const {
monitored,
qualityProfileId,
minimumAvailability,
// Id,
path,
tags
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('Edit')} - {title}
</ModalHeader>
<ModalBody>
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>{translate('Monitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="monitored"
helpText={translate('MonitoredHelpText')}
{...monitored}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('MinimumAvailability')}
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title={translate('MinimumAvailability')}
body={<MovieMinimumAvailabilityPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
{...minimumAvailability}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
{...qualityProfileId}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Path')}</FormLabel>
<FormInputGroup
type={inputTypes.PATH}
name="path"
{...path}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
{...tags}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteMoviePress}
>
{translate('Delete')}
</Button>
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerButton
isSpinning={isSaving}
onPress={this.onSavePress}
>
{translate('Save')}
</SpinnerButton>
</ModalFooter>
<MoveMovieModal
originalPath={originalPath}
destinationPath={path.value}
isOpen={this.state.isConfirmMoveModalOpen}
onModalClose={this.onCancelPress}
onSavePress={this.onSavePress}
onMoveMoviePress={this.onMoveMoviePress}
/>
</ModalContent>
);
}
}
EditMovieModalContent.propTypes = {
movieId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
item: PropTypes.object.isRequired,
isSaving: PropTypes.bool.isRequired,
isPathChanging: PropTypes.bool.isRequired,
originalPath: PropTypes.string.isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteMoviePress: PropTypes.func.isRequired
};
export default EditMovieModalContent;

View File

@@ -0,0 +1,235 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import MovieMinimumAvailabilityPopoverContent from 'AddMovie/MovieMinimumAvailabilityPopoverContent';
import AppState from 'App/State/AppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import usePrevious from 'Helpers/Hooks/usePrevious';
import {
icons,
inputTypes,
kinds,
sizes,
tooltipPositions,
} from 'Helpers/Props';
import MoveMovieModal from 'Movie/MoveMovie/MoveMovieModal';
import useMovie from 'Movie/useMovie';
import { saveMovie, setMovieValue } from 'Store/Actions/movieActions';
import selectSettings from 'Store/Selectors/selectSettings';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './EditMovieModalContent.css';
export interface EditMovieModalContentProps {
movieId: number;
onModalClose: () => void;
onDeleteMoviePress: () => void;
}
function EditMovieModalContent({
movieId,
onModalClose,
onDeleteMoviePress,
}: EditMovieModalContentProps) {
const dispatch = useDispatch();
const {
title,
monitored,
minimumAvailability,
qualityProfileId,
path,
tags,
} = useMovie(movieId)!;
const { isSaving, saveError, pendingChanges } = useSelector(
(state: AppState) => state.movies
);
const wasSaving = usePrevious(isSaving);
const isPathChanging = pendingChanges.path && path !== pendingChanges.path;
const [isConfirmMoveModalOpen, setIsConfirmMoveModalOpen] = useState(false);
const { settings, ...otherSettings } = useMemo(() => {
return selectSettings(
{
monitored,
minimumAvailability,
qualityProfileId,
path,
tags,
},
pendingChanges,
saveError
);
}, [
monitored,
minimumAvailability,
qualityProfileId,
path,
tags,
pendingChanges,
saveError,
]);
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error actions aren't typed
dispatch(setMovieValue({ name, value }));
},
[dispatch]
);
const handleCancelPress = useCallback(() => {
setIsConfirmMoveModalOpen(false);
}, []);
const handleSavePress = useCallback(() => {
if (isPathChanging && !isConfirmMoveModalOpen) {
setIsConfirmMoveModalOpen(true);
} else {
setIsConfirmMoveModalOpen(false);
dispatch(
saveMovie({
id: movieId,
moveFiles: false,
})
);
}
}, [movieId, isPathChanging, isConfirmMoveModalOpen, dispatch]);
const handleMoveMoviePress = useCallback(() => {
setIsConfirmMoveModalOpen(false);
dispatch(
saveMovie({
id: movieId,
moveFiles: true,
})
);
}, [movieId, dispatch]);
useEffect(() => {
if (!isSaving && wasSaving && !saveError) {
onModalClose();
}
}, [isSaving, wasSaving, saveError, onModalClose]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditMovieModalHeader', { title })}</ModalHeader>
<ModalBody>
<Form {...otherSettings}>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('Monitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="monitored"
helpText={translate('MonitoredMovieHelpText')}
{...settings.monitored}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>
{translate('MinimumAvailability')}
<Popover
anchor={<Icon className={styles.labelIcon} name={icons.INFO} />}
title={translate('MinimumAvailability')}
body={<MovieMinimumAvailabilityPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
{...settings.minimumAvailability}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
{...settings.qualityProfileId}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('Path')}</FormLabel>
<FormInputGroup
type={inputTypes.PATH}
name="path"
{...settings.path}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
{...settings.tags}
onChange={handleInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteMoviePress}
>
{translate('Delete')}
</Button>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
error={saveError}
isSpinning={isSaving}
onPress={handleSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
<MoveMovieModal
originalPath={path}
destinationPath={pendingChanges.path}
isOpen={isConfirmMoveModalOpen}
onModalClose={handleCancelPress}
onSavePress={handleSavePress}
onMoveMoviePress={handleMoveMoviePress}
/>
</ModalContent>
);
}
export default EditMovieModalContent;

View File

@@ -1,115 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveMovie, setMovieValue } from 'Store/Actions/movieActions';
import createMovieSelector from 'Store/Selectors/createMovieSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import EditMovieModalContent from './EditMovieModalContent';
function createIsPathChangingSelector() {
return createSelector(
(state) => state.movies.pendingChanges,
createMovieSelector(),
(pendingChanges, movie) => {
const path = pendingChanges.path;
if (path == null) {
return false;
}
return movie.path !== path;
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.movies,
createMovieSelector(),
createIsPathChangingSelector(),
(moviesState, movie, isPathChanging) => {
const {
isSaving,
saveError,
pendingChanges
} = moviesState;
const movieSettings = {
monitored: movie.monitored,
qualityProfileId: movie.qualityProfileId,
minimumAvailability: movie.minimumAvailability,
path: movie.path,
tags: movie.tags
};
const settings = selectSettings(movieSettings, pendingChanges, saveError);
return {
title: movie.title,
isSaving,
saveError,
isPathChanging,
originalPath: movie.path,
item: settings.settings,
...settings
};
}
);
}
const mapDispatchToProps = {
dispatchSetMovieValue: setMovieValue,
dispatchSaveMovie: saveMovie
};
class EditMovieModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.dispatchSetMovieValue({ name, value });
};
onSavePress = (moveFiles) => {
this.props.dispatchSaveMovie({
id: this.props.movieId,
moveFiles
});
};
//
// Render
render() {
return (
<EditMovieModalContent
{...this.props}
onInputChange={this.onInputChange}
onSavePress={this.onSavePress}
onMoveMoviePress={this.onMoveMoviePress}
/>
);
}
}
EditMovieModalContentConnector.propTypes = {
movieId: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
dispatchSetMovieValue: PropTypes.func.isRequired,
dispatchSaveMovie: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditMovieModalContentConnector);

View File

@@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import MovieHistoryModalContentConnector from './MovieHistoryModalContentConnector';
function MovieHistoryModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
size={sizes.EXTRA_EXTRA_LARGE}
>
<MovieHistoryModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
MovieHistoryModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default MovieHistoryModal;

View File

@@ -0,0 +1,28 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import MovieHistoryModalContent, {
MovieHistoryModalContentProps,
} from 'Movie/History/MovieHistoryModalContent';
interface MovieHistoryModalProps extends MovieHistoryModalContentProps {
isOpen: boolean;
}
function MovieHistoryModal({
isOpen,
onModalClose,
...otherProps
}: MovieHistoryModalProps) {
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE}
onModalClose={onModalClose}
>
<MovieHistoryModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default MovieHistoryModal;

View File

@@ -1,141 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import MovieHistoryRowConnector from './MovieHistoryRowConnector';
const columns = [
{
name: 'eventType',
isVisible: true
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: true
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: true
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true
},
{
name: 'customFormats',
label: () => translate('CustomFormats'),
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isSortable: true,
isVisible: true
},
{
name: 'date',
label: () => translate('Date'),
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
class MovieHistoryModalContent extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
onMarkAsFailedPress,
onModalClose
} = this.props;
const hasItems = !!items.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('History')}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>{translate('HistoryLoadError')}</Alert>
}
{
isPopulated && !hasItems && !error &&
<div>{translate('NoHistory')}</div>
}
{
isPopulated && hasItems && !error &&
<Table columns={columns}>
<TableBody>
{
items.map((item) => {
return (
<MovieHistoryRowConnector
key={item.id}
{...item}
onMarkAsFailedPress={onMarkAsFailedPress}
/>
);
})
}
</TableBody>
</Table>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
MovieHistoryModalContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default MovieHistoryModalContent;

View File

@@ -0,0 +1,148 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds } from 'Helpers/Props';
import {
clearMovieHistory,
fetchMovieHistory,
movieHistoryMarkAsFailed,
} from 'Store/Actions/movieHistoryActions';
import translate from 'Utilities/String/translate';
import MovieHistoryRow from './MovieHistoryRow';
const columns: Column[] = [
{
name: 'eventType',
label: '',
isVisible: true,
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: true,
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('CustomFormats'),
isSortable: false,
isVisible: true,
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'date',
label: () => translate('Date'),
isVisible: true,
},
{
name: 'actions',
label: '',
isVisible: true,
},
];
export interface MovieHistoryModalContentProps {
movieId: number;
onModalClose: () => void;
}
function MovieHistoryModalContent({
movieId,
onModalClose,
}: MovieHistoryModalContentProps) {
const dispatch = useDispatch();
const { isFetching, isPopulated, error, items } = useSelector(
(state: AppState) => state.movieHistory
);
const hasItems = !!items.length;
const handleMarkAsFailedPress = useCallback(
(historyId: number) => {
dispatch(
movieHistoryMarkAsFailed({
historyId,
movieId,
})
);
},
[movieId, dispatch]
);
useEffect(() => {
dispatch(fetchMovieHistory({ movieId }));
return () => {
dispatch(clearMovieHistory());
};
}, [movieId, dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('History')}</ModalHeader>
<ModalBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('HistoryLoadError')}</Alert>
) : null}
{isPopulated && !hasItems && !error ? (
<div>{translate('NoHistory')}</div>
) : null}
{isPopulated && hasItems && !error && (
<Table columns={columns}>
<TableBody>
{items.map((item) => {
return (
<MovieHistoryRow
key={item.id}
{...item}
onMarkAsFailedPress={handleMarkAsFailedPress}
/>
);
})}
</TableBody>
</Table>
)}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default MovieHistoryModalContent;

View File

@@ -1,76 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearMovieHistory, fetchMovieHistory, movieHistoryMarkAsFailed } from 'Store/Actions/movieHistoryActions';
import MovieHistoryModalContent from './MovieHistoryModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.movieHistory,
(movieHistory) => {
return movieHistory;
}
);
}
const mapDispatchToProps = {
fetchMovieHistory,
clearMovieHistory,
movieHistoryMarkAsFailed
};
class MovieHistoryModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
movieId
} = this.props;
this.props.fetchMovieHistory({
movieId
});
}
componentWillUnmount() {
this.props.clearMovieHistory();
}
//
// Listeners
onMarkAsFailedPress = (historyId) => {
const {
movieId
} = this.props;
this.props.movieHistoryMarkAsFailed({
historyId,
movieId
});
};
//
// Render
render() {
return (
<MovieHistoryModalContent
{...this.props}
onMarkAsFailedPress={this.onMarkAsFailedPress}
/>
);
}
}
MovieHistoryModalContentConnector.propTypes = {
movieId: PropTypes.number.isRequired,
fetchMovieHistory: PropTypes.func.isRequired,
clearMovieHistory: PropTypes.func.isRequired,
movieHistoryMarkAsFailed: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MovieHistoryModalContentConnector);

View File

@@ -1,181 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
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 RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
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 MovieLanguages from 'Movie/MovieLanguages';
import MovieQuality from 'Movie/MovieQuality';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './MovieHistoryRow.css';
class MovieHistoryRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isMarkAsFailedModalOpen: false,
isDetailsModalOpen: false
};
}
//
// Listeners
onMarkAsFailedPress = () => {
this.setState({ isMarkAsFailedModalOpen: true });
};
onConfirmMarkAsFailed = () => {
this.props.onMarkAsFailedPress(this.props.id);
this.setState({ isMarkAsFailedModalOpen: false });
};
onMarkAsFailedModalClose = () => {
this.setState({ isMarkAsFailedModalOpen: false });
};
onDetailsPress = () => {
this.setState({ isDetailsModalOpen: true });
};
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
};
//
// Render
render() {
const {
eventType,
sourceTitle,
quality,
customFormats,
customFormatScore,
languages,
qualityCutoffNotMet,
date,
data,
downloadId,
isMarkingAsFailed,
shortDateFormat,
timeFormat
} = this.props;
const {
isMarkAsFailedModalOpen
} = this.state;
return (
<TableRow>
<HistoryEventTypeCell
eventType={eventType}
data={data}
/>
<TableRowCell className={styles.sourceTitle}>
{sourceTitle}
</TableRowCell>
<TableRowCell>
<MovieLanguages
languages={languages}
/>
</TableRowCell>
<TableRowCell>
<MovieQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
<TableRowCell>
<MovieFormats formats={customFormats} />
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
<RelativeDateCell
date={date}
includeSeconds={true}
includeTime={true}
/>
<TableRowCell className={styles.actions}>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
{
eventType === 'grabbed' &&
<IconButton
title={translate('MarkAsFailed')}
name={icons.REMOVE}
size={14}
onPress={this.onMarkAsFailedPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={isMarkAsFailedModalOpen}
kind={kinds.DANGER}
title={translate('MarkAsFailed')}
message={translate('MarkAsFailedMessageText', [sourceTitle])}
confirmLabel={translate('MarkAsFailed')}
onConfirm={this.onConfirmMarkAsFailed}
onCancel={this.onMarkAsFailedModalClose}
/>
<HistoryDetailsModal
isOpen={this.state.isDetailsModalOpen}
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
onMarkAsFailedPress={this.onMarkAsFailedPress}
onModalClose={this.onDetailsModalClose}
/>
</TableRow>
);
}
}
MovieHistoryRow.propTypes = {
id: PropTypes.number.isRequired,
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired,
date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
downloadId: PropTypes.string,
isMarkingAsFailed: PropTypes.bool,
movie: PropTypes.object.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired
};
export default MovieHistoryRow;

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