1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-10 15:10:09 -04:00

Compare commits

..

326 Commits

Author SHA1 Message Date
Mark McDowall
9484904f60 New: Add button to close side bar
Closes #7757
2025-10-01 20:23:27 -07:00
Mark McDowall
a4f210855e Use react-query for Blocklist UI 2025-10-01 19:21:50 -07:00
Mark McDowall
bc4ad574fc Add v5 Blocklist endpoints 2025-10-01 19:21:50 -07:00
Mark McDowall
a5ea19ddfb New: Optional message for marking as failed via API
Closes #7775
2025-10-01 19:21:50 -07:00
Bogdan
858c690543 Remove redundant code in selecting with click on poster 2025-10-01 19:21:43 -07:00
Mark McDowall
8e169561f2 Adjust series details title line height to account for descenders 2025-10-01 19:21:36 -07:00
Mark McDowall
994faa60c6 New: Don't reparse OVA with folder name
Closes #8083
2025-10-01 19:21:26 -07:00
Mark McDowall
a4a18d6121 New: Support PEM format for SSL certificates
Closes #8087
2025-10-01 19:21:04 -07:00
Weblate
0407564784 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ArLab1 <arnaud.laberge@hotmail.com>
Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translation: Servarr/Sonarr
2025-10-01 19:20:55 -07:00
Bogdan
0a61e66ef1 Avoid rewriting file names in builds 2025-09-27 16:01:40 -07:00
Bogdan
051451eb2a Bump coverlet.collector to official 6.0.4 2025-09-27 16:01:40 -07:00
Mark McDowall
a4e3be721d New: Parse 'Por' as Portuguese
Closes #8057
2025-09-27 15:53:01 -07:00
Mark McDowall
224e74605b Fixed: Incorrectly parsing English subtitles as English audio 2025-09-27 15:53:01 -07:00
Collin Heist
6c581b7e3c Fixed: Prevent modals from overflowing screen width
Closes #8085
2025-09-27 15:50:27 -07:00
Bogdan
6588ba8435 Bump actions/checkout, actions/download-artifact and actions/setup-dotnet 2025-09-27 15:50:00 -07:00
Bogdan
ce4c2e4fcc Test versions 16 and 17 of Postgres 2025-09-27 15:50:00 -07:00
Bogdan
b1f77007dc Switch HttpProxySettingsProviderFixture to test cases 2025-09-27 15:48:39 -07:00
Bogdan
8bab0a06dd Attempt to remove pid file only if config folder exists 2025-09-27 15:48:28 -07:00
Bogdan
5b135addaa Fix legacy app data folder path on OSX 2025-09-27 15:48:28 -07:00
Stevie Robinson
4904e85887 New: Switch theme automatically on system change
Closes #8068
2025-09-27 15:48:06 -07:00
Bogdan
c4978022eb Fix clearing pending changes for First Run
`TypeError: can't access property "section", a is undefined`
2025-09-27 15:47:28 -07:00
Brian Wentzloff
15e9350601 Add missing word to 'AddNewSeriesHelpText' English translation 2025-09-27 15:47:21 -07:00
Bogdan
2e1289b924 New: Retry SQLite writes for database is locked errors 2025-09-27 15:46:45 -07:00
Bogdan
7dac00d5aa Bump System.Data.SQLite to official 2.0.2
Bump sqlite3 to 3.50.4
2025-09-27 15:46:37 -07:00
Bogdan
3796c9e30f Bump FluentMigrator to official 6.2.0 2025-09-27 15:46:32 -07:00
Mark McDowall
f2f4edad0c New: Parse Chinese season packs and multi-episode releases
Closes #8042
2025-09-27 15:41:59 -07:00
Sonarr
b0b15c78ff Automated API Docs update
ignore-downstream
2025-09-27 15:41:51 -07:00
Mark McDowall
64c421c187 New: Subtitles indexer flag to indicate releases with subtitles
Closes #7625
2025-09-27 15:41:45 -07:00
Tro95
6440151053 New: Excluded Tags on Release Profile 2025-09-27 15:41:33 -07:00
sparky3387
cf6b21aef6 New: Setting to allow for grabbing season packs even if some episodes already meet cutoff
Closes #6378
2025-09-27 15:40:07 -07:00
Stevie Robinson
1610e54650 correct migration number and fix for postgres 2025-09-01 15:08:37 -07:00
Mark McDowall
c40fbeed50 New: Parse Chinese and English titles as separate titles
Closes #8035
2025-09-01 15:08:31 -07:00
Mark McDowall
b57e7e2db0 New: Parse releases using Temporada as the season
Closes #8030
2025-09-01 15:08:31 -07:00
Mark McDowall
478866b2bb New: Changing icon during import to blue
Closes #7992
2025-09-01 15:08:24 -07:00
Mark McDowall
ae201f5299 Use react-query for queue UI
New: Season packs and multi-episode releases will show as a single item in the queue
Closes #6537
2025-09-01 15:08:19 -07:00
Mark McDowall
642f4f97bc Add v5 queue endpoints 2025-09-01 15:08:19 -07:00
Alexander WB
37cb978f18 New: RQBit download client
Co-authored-by: Mark Mendoza <markolo25@gmail.com>
2025-09-01 15:08:03 -07:00
康小广
7fdc4d6638 Follow redirects when fetching Custom Lists 2025-09-01 14:59:34 -07:00
grapexy
309b55fe38 New: Georgian language 2025-09-01 14:58:40 -07:00
oxfordllama
d6f265c7b5 Subtitles indexer flag to indicate BTN releases with subtitles 2025-09-01 14:58:19 -07:00
Weblate
e757dca038 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ArLab1 <arnaud.laberge@hotmail.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: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: NanderTGA <nander.roobaert@gmail.com>
Co-authored-by: ReDFiRe <wwsoft@abv.bg>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Xoores <servarr-35466@xoores.cz>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: mrchonks <chonkstv@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-09-01 14:56:39 -07:00
Mark McDowall
9ebe043bd9 New: Move auth success logging to debug
Closes #7978
2025-08-10 21:26:53 -07:00
Mark McDowall
f055e8a3e5 Fixed: Parsing English as the second language in a release name
Closes #8006
2025-08-10 21:26:39 -07:00
Sonarr
8c697afa67 Automated API Docs update
ignore-downstream
2025-08-10 21:20:03 -07:00
Weblate
8d68879edd Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ArLab1 <arnaud.laberge@hotmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translation: Servarr/Sonarr
2025-08-10 21:19:54 -07:00
Trey Turner
e9c82078da Fixed: File air date being updated every refresh
Closes #7989
2025-08-10 21:18:34 -07:00
Stevie Robinson
f0798550af New Include Finale type in Webhook and Custom Script connections
Closes #7999
2025-08-10 21:10:48 -07:00
Mark McDowall
d9c7838329 Fixed: Sub group parsing could result in extra brackets being parsed
Closes #7994
2025-08-10 21:10:11 -07:00
Mark McDowall
b00229e53c Fixed: Treat TaoE and QxR as release group instead of encoder
Closes #7972
2025-08-10 21:10:11 -07:00
Luigi
880628fb68 New: Select with poster click in series selection 2025-08-10 21:09:50 -07:00
Stevie Robinson
b09c6f0811 New: Include Mal and AniList IDs in API response and Webhooks
Closes #7973
2025-08-10 21:08:25 -07:00
Mark McDowall
b376b63c9e New: Parse '(JA)' as Japanese
Closes #7956
2025-08-10 21:07:32 -07:00
bparkin1283
99feaa34d2 Replace service --status-all with systemctl is-active 2025-08-10 21:07:27 -07:00
Stevie Robinson
d7f82a72c2 Fixed: Update nzb.su domain to nzb.life 2025-08-10 21:06:41 -07:00
Stevie Robinson
bd20ebfad7 New: Indexer option for Season Pack Seed Ratio 2025-08-10 21:06:20 -07:00
jutoft
71553ad67b New: Tribler 8 download client
Closes #1813
2025-08-10 21:05:40 -07:00
Weblate
41c39f1f28 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ArLab1 <arnaud.laberge@hotmail.com>
Co-authored-by: Dino <me@dinodev.org>
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: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: mrchonks <chonkstv@gmail.com>
Co-authored-by: myrad2267 <myrad2267@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-08-10 21:04:57 -07:00
Mark McDowall
d0066358eb Upgraded SixLabors.ImageSharp to 3.1.11 2025-08-01 09:31:00 -07:00
Weblate
6f1d461dad Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: wavybox <leeviervoemil@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translation: Servarr/Sonarr
2025-07-07 17:38:19 -07:00
Stevie Robinson
6ccab3cfc8 Fixed: Improve parsing of bit depth 2025-07-07 17:37:45 -07:00
Mark McDowall
5e47cc3baa Fixed: Improve parsing of 4-digit absolute numbering batches 2025-07-07 17:37:19 -07:00
Mark McDowall
78ca30d1f8 Don't log debug messages for API key validation
Closes #7934
2025-07-07 17:37:10 -07:00
Mark McDowall
f9d0abada3 Fixed: Manual Import could lead to duplicate notifications
Closes #7922
2025-07-07 17:37:02 -07:00
Mark McDowall
4bdb0408f1 Return error if Manual Import called without items
Closes #7942
2025-07-07 17:36:53 -07:00
Stevie Robinson
40ea6ce4e5 New: Persist queue removal option in browser
Closes #7938
2025-07-07 17:36:45 -07:00
nuxen
ccf33033dc Fixed: xvid not always detected correctly 2025-07-07 17:36:06 -07:00
Mark McDowall
996c0e9f50 Upgrade MonoTorrent to 3.0.2
Closes #7270
2025-07-07 17:35:37 -07:00
Stevie Robinson
8b7f9daab0 Improve Emby/Jellyfin connection test 2025-07-07 17:35:32 -07:00
Mark McDowall
dfb6fdfbeb Change authentication to Forms if set to Basic 2025-07-07 17:34:34 -07:00
Mark McDowall
29d0073ee6 Make remove root folder Sonarr specific 2025-07-07 17:34:34 -07:00
Mark McDowall
9cf6be32fa Fixed deleting tags from UI 2025-07-07 17:34:34 -07:00
Ben Martin
fee3f8150e New: Option to add series tags when adding torrents to qbit
Closes #7930
2025-07-07 17:34:25 -07:00
nuxen
010bbbd222 Support for multiple seriesIds in Rename API endpoint 2025-07-07 17:33:40 -07:00
ricecrackerfiend
d3c3a6ebce Fixed: Prevent long series titles from cutting off synopsis
Closes #7875
2025-07-07 17:33:15 -07:00
Stevie Robinson
f26344ae75 New: Allow user to define additional rejected extensions
Closes #7721
2025-07-07 17:32:34 -07:00
Weblate
034f731308 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Gjur0 <denjy0@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: HanaO00 <lwin24452@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Marius Nechifor <flm.marius@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-07-07 17:32:02 -07:00
Mark McDowall
4b50861a6b Revert "Added 'libicu72' as a required package for Debian install script"
This reverts commit 103b1335b9.
2025-06-10 06:33:52 -07:00
Weblate
f977b8ba1b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translation: Servarr/Sonarr
2025-06-09 17:22:15 -07:00
Bogdan
8374ebc25b Fixed: Buttons cutoff when page toolbar shows all buttons on smaller screens 2025-06-09 17:16:19 -07:00
Michael Peleshenko
71851d038c Fixed: Prevent series without IMDB ID from being removed erroneously 2025-06-09 17:15:36 -07:00
Stevie Robinson
9ffcd141a5 Fixed: Include network drive types in Disk Space 2025-06-09 17:14:13 -07:00
Bogdan
a6f50408f2 Fixed: Quality sliders on some browsers 2025-06-09 17:13:41 -07:00
Bogdan
6e43b08dab Typings for iCal state options 2025-06-09 17:13:17 -07:00
Bogdan
90c4791d5f Fix status for grabbed episodes part of grouped calendar events 2025-06-09 17:13:17 -07:00
v3DJG6GL
030910babc New: Map Swiss German to German 2025-06-09 17:12:58 -07:00
Stevie Robinson
59af86cea4 New: Rename Series Monitoring to Episode Monitoring 2025-06-09 17:12:33 -07:00
Bogdan
4cb25228b6 Cleanup series title normalizer 2025-06-09 17:12:09 -07:00
Stevie Robinson
bf34b43094 Fixed validation for Remote Path Mapping 2025-06-09 17:12:04 -07:00
Bogdan
1cdca8ef3e Follow redirects for usenet grabs on non-prod builds 2025-06-09 17:11:26 -07:00
Cybertinus
103b1335b9 Added 'libicu72' as a required package for Debian install script 2025-06-09 17:11:17 -07:00
Ghworg
b3d830c475 Fixed: Bitrate showing as Megabytes instead of Megabits
Closes #7879
2025-06-09 17:10:33 -07:00
Mark McDowall
a279240335 Fixed: Trakt Import List authentication after 24 hours
Closes #7874
2025-06-09 17:09:53 -07:00
Mark McDowall
3eed84c679 Prevent should refresh series from failing
Fixed: Prevent error checking if series should be refreshed from failing refresh series task
2025-06-09 17:09:41 -07:00
Mark McDowall
51c17fd312 New: Update wording when removing a root folder
Closes #7868
2025-06-09 17:09:28 -07:00
Mark McDowall
70c74fc176 Fixed: Escape backticks in discord notifications
Closes #7836
2025-06-09 17:09:23 -07:00
Weblate
cfda24536c 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: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Ilbebino <tommasobellandi08@gmail.com>
Co-authored-by: NanderTGA <nander.roobaert@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-06-09 16:56:03 -07:00
Bogdan
14e324ee30 Use the thrown exception in http timeout handling 2025-05-22 01:58:54 +01:00
Bogdan
32ba06ecd0 Bump babel, fontawesome icons, fuse.js, react-lazyload, react-use-measure and react-window 2025-05-22 01:58:17 +01:00
Bogdan
61807fede0 Bump core-js to 3.42 2025-05-22 01:58:17 +01:00
Bogdan
2a1efe5f59 Fixed: Searching all missing from Wanted 2025-05-22 01:58:10 +01:00
Bogdan
0f43f8c9f6 Fixed: Loading suggestions for header search input 2025-05-22 01:58:04 +01:00
Stevie Robinson
a853c537db new: ignore volumes containing .timemachine from Disk Space 2025-05-22 01:57:50 +01:00
Bogdan
f9dccd6ec7 New: Include series poster for Apprise 2025-05-22 01:57:41 +01:00
v3DJG6GL
72b3b825eb New: Add Romansh language 2025-05-21 17:57:33 -07:00
carrossos
818ae02a7a Treat HTTP 410 response for failed download similarly to HTTP 404 2025-05-21 17:57:11 -07:00
Stevie Robinson
5ba3ff5987 New: Don't allow remote path to start with space 2025-05-21 17:56:15 -07:00
Mark McDowall
e38deb3422 Increase maximum backup restoration size to 5GB
Closes #7840
2025-05-22 01:52:30 +01:00
Bogdan
f35888e053 Avoid varying logging message template between calls 2025-05-22 01:52:24 +01:00
Stevie Robinson
a50d256264 Ensure Custom Format Maximum Size won't overflow 2025-05-21 17:52:16 -07:00
Weblate
70165bddc8 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: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: warkurre86 <tom.novo.86@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-05-22 01:41:49 +01:00
Sonarr
4258e94e90 Automated API Docs update
ignore-downstream
2025-04-28 17:18:44 -07:00
Mark McDowall
066b39032b Fixed: Parsing titles with 3 digit season numbers
Closes #7826
2025-04-28 17:14:19 -07:00
Mark McDowall
728df146ad Improve messaging when NZB contains invalid XML
Closes #7829
2025-04-28 17:14:09 -07:00
Bogdan
751a07bb40 Fixed sidebar flickering on mobile 2025-04-28 17:13:59 -07:00
Bogdan
d4ce60bd41 Bump caniuse db 2025-04-28 17:13:43 -07:00
Bogdan
4b868d3f06 Bump core-js to 3.41 2025-04-28 17:13:43 -07:00
Bogdan
817d13e85c Update default log level message 2025-04-28 17:12:51 -07:00
Bogdan
fae014c8be Fix various typos 2025-04-28 17:12:41 -07:00
Bogdan
2fa02472ee Fixed auto tagging tag specification 2025-04-28 17:12:33 -07:00
Bogdan
7c3c577811 Fixed selecting No Change for quality profile inputs 2025-04-28 17:12:10 -07:00
Bogdan
9fdf545f47 Improve typings for enhanced select options 2025-04-28 17:11:50 -07:00
Bogdan
e537a2dc8f Improve typings for Select Input options 2025-04-28 17:11:50 -07:00
Bogdan
1047e71b7d Log when expected episode file is missing from disk on upgrade 2025-04-28 17:11:25 -07:00
Bogdan
415498efb3 Fixed free space and missing for selected root folder value 2025-04-28 17:11:13 -07:00
Bogdan
cf08e947c4 Fixed grouping qualities inside a quality profile 2025-04-28 17:10:54 -07:00
Bogdan
bb872ee35b Add SeriesFolderController for v5 2025-04-28 17:10:09 -07:00
Bogdan
ab0d8352e8 Clean up progress log messages in ImportListSyncService 2025-04-28 17:09:32 -07:00
Bogdan
9683b0af35 Pass messages with arguments to NLog in LoggerExtensions 2025-04-28 17:09:32 -07:00
Bogdan
76b1130b68 Fixed: Sending Manual Interaction Needed for Custom Script with unparsed series
Closes #7807
2025-04-28 17:09:14 -07:00
Jamie Bartlett
5be58249f8 New: Auto tag based on network
Closes #7793
2025-04-28 17:08:57 -07:00
Weblate
4d67b8ae2b 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: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: José Fonseca <jpof88@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-04-28 17:08:12 -07:00
ojmaster
66633b9b07 New: Add Urdu language 2025-04-07 17:02:48 -07:00
Bogdan
4728fa29ef Fixed: Avoid requests without categories for FileList 2025-04-07 16:45:02 -07:00
Bogdan
9cb9c711be Fixed: Respect series type in Newznab/Torznab searches for specials
Closes #7783
2025-04-07 16:45:02 -07:00
Bogdan
d62eea604a Validation for tags label 2025-04-07 16:43:58 -07:00
Weblate
3185315343 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translation: Servarr/Sonarr
2025-04-07 16:43:11 -07:00
Mark McDowall
e52b68ee7d Fix issues with Floating UI 2025-04-07 16:42:47 -07:00
Bogdan
f7eece32e7 Log delete statements only once 2025-04-07 16:42:36 -07:00
Stevie Robinson
c96c47af9e Add size validation for Quality Profiles 2025-04-07 16:41:22 -07:00
Tim Gunter
a5999b1410 Add command-line argument and unattended mode for Debian installs 2025-04-07 16:40:53 -07:00
Mark McDowall
ac1bb497ef Fixed: Default runtime to 45 minutes if unavailable when importing episode files
Closes #7780
2025-04-07 16:38:33 -07:00
Mark McDowall
9bd619ccfe Update WikiUrl type in API docs
Closes #7788
2025-04-07 16:38:24 -07:00
Mark McDowall
dfbf12b711 Fixed: Delete orphaned extra and subtitle files during housekeeping
Closes #7785
2025-04-07 16:38:20 -07:00
Bogdan
0ae07898ba Fixed: Improve error message for queue items from Transmission 2025-04-07 16:38:09 -07:00
Stevie Robinson
2314d0b506 New: Ability to clone Import Lists
Closes #7758
2025-04-07 16:34:41 -07:00
Josh Archer
2093f08a57 Fixed: Include total space in root folder endpoint
Closes #7769
2025-04-07 16:33:44 -07:00
Bogdan
0a7ffb64f0 Save Publish Dates as UTC for grabbed episodes 2025-04-07 16:33:16 -07:00
Mark McDowall
41b65abd1d New: Show episode count in season interactive search modal
Closes #7747
2025-04-07 16:33:09 -07:00
Mark McDowall
0f904e0917 New: Prevent Remote Path Mapping local folder being set to System folder or '/'
Closes #7686
2025-04-07 16:32:50 -07:00
Mark McDowall
f8e57b0985 Fixed: Set output encoding to UTF-8 when running external processes
Closes #7622
2025-04-07 16:32:46 -07:00
Bogdan
9e774f4026 New: Indexer Flags in Webhook Import Notifications 2025-04-07 16:32:40 -07:00
Stevie Robinson
2acc4c8865 New: Indexer Flags in Webhook On Grab Notifications
Closes #7741
2025-04-07 16:32:13 -07:00
Weblate
0fcd92e441 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: José Fonseca <jpof88@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translation: Servarr/Sonarr
2025-04-07 16:31:37 -07:00
Mark McDowall
b103005aa2 Add XML declaration and clean up Kodi metadata generation
Closes #7753
2025-03-24 19:40:20 -07:00
Sonarr
41b5118938 Automated API Docs update
ignore-downstream
2025-03-24 19:38:54 -07:00
Mark McDowall
c84699ed5d Fixed: Deleting series folder fails when files/folders aren't instantly removed
Closes #7749
2025-03-24 19:38:53 -07:00
Mark McDowall
bdd975da0f Fixed: Trakt yearly lists no longer supported
Closes #7759
2025-03-24 19:38:53 -07:00
Mark McDowall
08d1bcb351 Fixed: Series search input when URL base is configured 2025-03-24 19:32:11 -07:00
Mark McDowall
5fb632eb46 Fixed: Allow tables to scroll on tablets in portrait mode
Closes #7723
2025-03-24 19:30:31 -07:00
Mark McDowall
da29de4cfe Fixed: Don't allow pushed releases to bypass pending releases that recently expired
Closes #7725
2025-03-24 19:30:08 -07:00
Mark McDowall
83b2c9e97a New: Parse TAoE as release group exception
Closes #7736
2025-03-24 19:30:01 -07:00
Mark McDowall
095126bfe8 New: Parse UK date based release format
Closes #7695
2025-03-24 19:29:53 -07:00
Bogdan
6aee9c7fd5 Don't allow to set episode files with 'Original' language 2025-03-24 19:28:20 -07:00
Bogdan
20c2d59e9a Deprecate /api/v3/episodefile/editor 2025-03-24 19:28:20 -07:00
Bogdan
7ee90fb05d Bump Swashbuckle to 8.0.0 2025-03-24 19:28:20 -07:00
Bogdan
1a8ba51260 Improve Series Details loading 2025-03-24 19:28:11 -07:00
Stevie Robinson
2e66cd2a1e New: Add series progress to details header 2025-03-24 19:27:59 -07:00
Bogdan
6115236d38 Cleanup unused sorting fields for bulk manage providers 2025-03-24 19:27:24 -07:00
Bogdan
7ff8c9e18d New: Bulk manage Maximum Single Episode Age for indexers 2025-03-24 19:27:24 -07:00
Bogdan
f0e320f3aa Fixed: Priority validation for indexers and download clients 2025-03-24 19:27:24 -07:00
Bogdan
9f29b06ca4 Fixed: Close modal when deleting series from index 2025-03-24 19:26:47 -07:00
Bogdan
08c0c5aa30 Fixed: Manual importing queued items with seriesId to avoid title parsing 2025-03-24 19:26:39 -07:00
fezster
64956d7be7 New: Add HDR Type to XBMC metadata video stream details
(cherry picked from commit a7dbdadd2146b60efa7ebe8e2b65d32bc075232c)
Co-Authored-By: Bogdan <mynameisbogdan@users.noreply.github.com>
2025-03-24 19:26:28 -07:00
Sonarr
b598795262 Automated API Docs update
ignore-downstream
2025-03-24 19:25:51 -07:00
Weblate
9756a3df38 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Alex <despedo@gmail.com>
Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/uk/
Translation: Servarr/Sonarr
2025-03-24 19:25:43 -07:00
Bogdan
94f64435f5 Fixed: Spinning icon on toggling series monitoring 2025-03-15 16:40:20 -07:00
Bogdan
a324052deb New: Display indexer in download failed details 2025-03-15 16:40:10 -07:00
Bogdan
e08c9d5501 Fixed: Inherit indexer, size and release group for marked as failed history 2025-03-15 16:40:10 -07:00
Mark McDowall
1449941471 Improve logging when login fails due to CryptographicException 2025-03-15 16:39:40 -07:00
Mark McDowall
28c4f0cef2 Remove react-popper 2025-03-15 16:39:19 -07:00
Mark McDowall
e199710c15 Fix multi-select checkboxes not appearing 2025-03-15 16:39:19 -07:00
Mark McDowall
e3a048790d Use floading UI for EnhancedSelectInput 2025-03-15 16:39:19 -07:00
Mark McDowall
6dc47755ec Use floating UI for ImportSeriesSelectSeries 2025-03-15 16:39:19 -07:00
Mark McDowall
01f7783519 Use floating UI for AutoSuggestInput 2025-03-15 16:39:19 -07:00
Mark McDowall
e9f59188b1 Use floating UI for Menu 2025-03-15 16:39:19 -07:00
Mark McDowall
a6e6b7518d Use floating UI for Tooltip
Fixed: Tooltips cutoff by edge of screen
Closes #7705
2025-03-15 16:39:19 -07:00
luzpaz
38cd63ec04 Fix typos 2025-03-15 16:39:09 -07:00
Bogdan
71f1593fd9 Fix import typo for QualityProfileName 2025-03-15 16:38:11 -07:00
Mark McDowall
3d951f6db8 Convert Updates to React Query 2025-03-15 16:38:01 -07:00
Mark McDowall
0f16837b59 Improve RestController usage for non-ID based items 2025-03-15 16:38:01 -07:00
Mark McDowall
f0d0eb9a7a Re-add repopulate reasons lost during TS conversion 2025-03-15 16:37:52 -07:00
Mark McDowall
608fc29086 Fixed: Unknown series slug redirecting back to home instad of error screen 2025-03-15 16:37:52 -07:00
Mark McDowall
8acd154206 Fixed: UI does not always reflect series being refreshed 2025-03-15 16:37:52 -07:00
Denis Gheorghescu
6f451b2206 New: Options to send Image and Metadata links for Pushcut Notifications
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2025-03-15 16:37:40 -07:00
Sonarr
3cb6f866cc Automated API Docs update
ignore-downstream
2025-03-15 16:37:07 -07:00
Weblate
649ed04f8a 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: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-03-15 16:36:59 -07:00
Mark McDowall
fef00dccf8 Fix uTorrent after stylecop upgrade 2025-03-08 12:57:42 -08:00
Bogdan
cbc5127a14 Bump SixLabors.ImageSharp, Dapper, MailKit, Polly and Npgsql 2025-03-08 12:57:42 -08:00
Weblate
38746fc95b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translation: Servarr/Sonarr
2025-03-08 12:57:33 -08:00
Mark McDowall
24ce10006e Show Remove Failed option in torrent download clients 2025-03-08 12:34:14 -08:00
Mark McDowall
c034282f45 Fixed: Downloads failed for file contents will be removed from client 2025-03-08 12:34:14 -08:00
Stevie Robinson
b2214fd912 Align quality profile translations with Radarr 2025-03-08 12:34:06 -08:00
Stevie Robinson
4db4388236 Fixed: Improve rejected download handling 2025-03-08 12:33:59 -08:00
Mark McDowall
093ee5b88d New: Truncate button text
Closes #7691
2025-03-08 12:33:11 -08:00
Mark McDowall
f58dfc5605 Improve wrapping of text in sidebar 2025-03-08 12:33:11 -08:00
Mark McDowall
1c9a0232ad Fix log event details not closing 2025-03-08 12:33:03 -08:00
Mark McDowall
c86822b114 Upgrade 'eslint-plugin-react-hooks' to 5.2.0 2025-03-08 12:33:03 -08:00
Mark McDowall
5342416659 Convert Log Events to React Query 2025-03-08 12:33:03 -08:00
Mark McDowall
5d7c94f8e9 Upgrade StyleCop.Analyzers to Unstable 1.2.0.556 2025-03-08 12:33:03 -08:00
Bogdan
6d8c3f15b3 New: Recommend against using uTorrent 2025-03-08 12:32:52 -08:00
Weblate
efef9f88ff Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translation: Servarr/Sonarr
2025-03-08 12:32:26 -08:00
Mark McDowall
8748cdb1bf Upgrade Microsoft.Data.SqlClient to 6.0.1 2025-03-01 20:54:19 -08:00
Stevie Robinson
7a9b6d3262 Remove information that Remove settings were moved to individual download clients 2025-03-01 20:54:10 -08:00
Stevie Robinson
c902735927 Enhance failed download warning for items not grabbed by Sonarr 2025-03-01 20:53:29 -08:00
Bogdan
156d306334 Fixed: Replace diacritics in Clean Title naming tokens
Closes #454
2025-03-01 20:53:04 -08:00
Bogdan
d09e7893b3 Fixed: Parsing some titles with ITA as Italian
(cherry picked from commit a3b1512552a8a5bc0c0d399d961ccbf0dba97749)
2025-03-01 20:52:46 -08:00
bakerboy448
3e2e8e9388 New: Parse 'FRA' and 'FRE' as French language
(cherry picked from commit 4ce98d689d7a7c5e33d4c63d78c099db379210fa)

Fixed: Parsing of French Language at end of release names

(cherry picked from commit f8a82dbb904acf68bc4b7fc9980f3765c8b88369)

Fixed: Parsing some titles with FRA and FRE as French

(cherry picked from commit a3b1512552a8a5bc0c0d399d961ccbf0dba97749)
2025-03-01 20:52:46 -08:00
Mark McDowall
99fc61e636 New: Add Azerbaijani, Uzbek and Malay languages
Closes #6285
Closes #7560
2025-03-01 20:52:18 -08:00
Bogdan
271979d637 Fix Watch List Sorting translation for Trakt settings 2025-03-01 20:52:06 -08:00
Weblate
b572a6f759 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: keysuck <joshkkim@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-03-01 20:51:56 -08:00
Mark McDowall
950330b091 Use yarn as package manager 2025-02-22 12:42:06 -08:00
Mark McDowall
8940ef8b81 Ensure scripts are executable 2025-02-22 12:42:06 -08:00
Mark McDowall
91bdf06214 Update LibHarmony to 2.3.5 2025-02-22 12:42:05 -08:00
Mark McDowall
5678f98344 API docs for v5 series 2025-02-22 12:42:05 -08:00
Mark McDowall
05d57aa913 Ensure API docs are generated before exiting 2025-02-22 12:42:05 -08:00
Mark McDowall
b22d598ebf Move test.sh to scripts folder 2025-02-22 12:42:05 -08:00
Bogdan
e22dd15443 Fixed: Avoid checking for free space if other specifications fail first 2025-02-22 12:42:05 -08:00
Stevie Robinson
1fa532dd3e Fixed: Don't return warning in title field for rejected downloads
Closes #7663
2025-02-22 12:42:00 -08:00
Bogdan
350dea10dd Fixed: Prevent 8/10/24-bit from being parsed as release group 2025-02-22 12:30:51 -08:00
Bogdan
d3777dd43c Update translations for Trakt settings to be series specific 2025-02-22 12:30:28 -08:00
v3DJG6GL
a72288a14e New: .arj and .lzh extensions are potentially dangerous 2025-02-22 12:30:10 -08:00
Weblate
e506eb6d03 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: corwin007x <skopal.ondrej@gmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2025-02-22 12:29:01 -08:00
Mark McDowall
591b569bdd Add Series with ReactQuery Mutation 2025-02-18 19:27:02 -08:00
Mark McDowall
094df71301 New: Option for Telegram link previews
Closes #7500
2025-02-18 19:26:48 -08:00
Stevie Robinson
31e02bdead Fixed: Rejected Imports with no associated release or indexer 2025-02-18 19:26:38 -08:00
Mark McDowall
b122ee9670 Fixed: Only show Additional Parameters on Trakt Popular list
Closes #7575
2025-02-18 19:25:52 -08:00
Mark McDowall
0f9e063e21 New: Remove Basic Auth
Closes #7597
2025-02-18 19:25:35 -08:00
Mark McDowall
609e964794 Cleanse console log messages
Closes #7632
2025-02-18 19:25:23 -08:00
Mark McDowall
5cebec6ae4 New: Support TMDb and IMDB IDs in Custom Import Lists 2025-02-18 19:25:12 -08:00
Mark McDowall
33da537a63 New: Remove defunct IMDB Import Lists
Closes #6915
2025-02-18 19:25:12 -08:00
Bogdan
11c945c2dc Fixed: Download links for FileList when passkey contains spaces 2025-02-18 19:24:54 -08:00
Bogdan
1b0a20535c New: Remove deprecated Kometa metadata 2025-02-18 19:24:37 -08:00
Bogdan
1734fcaa8f Fixed: Close Metadata settings modal on saving 2025-02-18 19:23:51 -08:00
Stevie Robinson
b99e06acc0 Fixed: Fallback to Instance Name for Discord notifications 2025-02-18 19:23:43 -08:00
Bogdan
5d16169e52 Fixed: Improve parsing of some episodes using day-month-year format 2025-02-18 19:23:05 -08:00
Weblate
a2670a5804 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: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-02-18 19:22:08 -08:00
Stevie Robinson
9e5ebdc624 Fixed: Parse GER/DE releases as German language
Closes #7635

Co-authored-by: epmt7w3ugk <epmt7w3ugk@bypfk.anonbox.net>
2025-02-11 19:37:15 -08:00
Stevie Robinson
e62aa5e041 Fixed: SABnzbd sorting mode not reflected in heath check 2025-02-11 19:37:15 -08:00
Connor Gallopo
80a8176c58 Update date in readme to 2025 2025-02-11 19:37:14 -08:00
Bogdan
39f1c669b8 Fix typo in Deleted Episode File Specification 2025-02-11 19:37:14 -08:00
Bogdan
5208f5e966 New: Refresh cache for tracked queue on series update 2025-02-11 19:37:14 -08:00
Bogdan
bb0ad312f1 Fixed: Health warning for downloading inside root folders 2025-02-11 19:37:14 -08:00
Mark McDowall
413d6b996b Fixed: Ignore special folders inside Blackhole watch folders 2025-02-11 19:37:13 -08:00
Bogdan
79474f26e9 New: Prefer newer Usenet releases 2025-02-11 19:37:13 -08:00
Mark McDowall
18bbb8bbd1 Add MediaInfo AudioLanguagesAll and update styling
Closes #7587
2025-02-11 19:37:13 -08:00
Bogdan
374c6d13f6 Fixed: Format bitrate for primary streams in media info
Co-authored-by: Mark McDowall <markus.mcd5@gmail.com>
2025-02-11 19:37:12 -08:00
Sonarr
92f0aa4e8f Automated API Docs update
ignore-downstream
2025-02-11 19:37:12 -08:00
Mark McDowall
7345c06003 Don't publish v5 builds yet 2025-02-11 19:37:12 -08:00
Mark McDowall
ff08b914f3 Fix tests for v5 2025-02-11 19:37:12 -08:00
Mark McDowall
e40c8f3e3e Improve build speed 2025-02-11 19:37:11 -08:00
Mark McDowall
2fd1fea4cb Fix builds with v5 API 2025-02-11 19:36:00 -08:00
Mark McDowall
a72bc164a9 Fix import order after TS 2025-02-11 19:35:59 -08:00
Mark McDowall
d9a86bcb31 Build and conflict labeler for PRs against v5-develop 2025-02-11 19:35:59 -08:00
Mark McDowall
553c4aeae1 v5 API docs 2025-02-11 19:35:59 -08:00
Mark McDowall
5a47f34ef9 Add v5 API 2025-02-11 19:35:59 -08:00
Mark McDowall
15b070119d Fix signalR 2025-02-11 19:35:58 -08:00
Steel City Phantom
7b133bd80d Auto-detect building on macOS ARM 2025-02-11 19:35:58 -08:00
Stevie Robinson
ce6536f8ab New: Show size in history details
Closes #7594
2025-02-11 19:35:58 -08:00
Bogdan
c6eb6c3cd8 Typing for Interactive Search payload 2025-02-11 19:35:58 -08:00
Bogdan
f4f3fdfb0b New: Migrate appdata folder for .NET 8 on OSX 2025-02-11 19:35:57 -08:00
Bogdan
6b92627004 New: Bump to .NET 8 2025-02-11 19:35:57 -08:00
Mark McDowall
c3f9cd12af Convert signalR to TypeScript 2025-02-11 19:35:57 -08:00
Mark McDowall
6f871a1bfb Convert Import Series to TypeScript 2025-02-11 19:35:57 -08:00
Mark McDowall
ab1f8bdbd9 Remove pnpm-lock.yaml 2025-02-11 19:35:56 -08:00
Mark McDowall
b72b1d5e5c Remove defaultProps from SeriesIndexFilterMenu 2025-02-11 19:35:56 -08:00
Mark McDowall
87c840974b Convert Add New Series to TypeScript 2025-02-11 19:35:56 -08:00
Mark McDowall
ee46e6378a Remove Measure 2025-02-11 19:34:46 -08:00
Mark McDowall
7644cec376 Convert Series Details to TypeScript 2025-02-11 19:34:46 -08:00
Mark McDowall
d3153685ac Convert Delete Series Modal to TypeScript 2025-02-11 19:34:46 -08:00
Mark McDowall
7035bb2944 Convert Series History to TypeScript 2025-02-11 19:34:46 -08:00
Mark McDowall
6dc16f3ddd Fixed Import List CleanLibraryLevel Options 2025-02-11 19:34:45 -08:00
Mark McDowall
0e1474579a Convert Monitoring Options to TypeScript 2025-02-11 19:34:45 -08:00
Mark McDowall
7b4bd50f18 Convert MoveSeriesModal to TypeScript 2025-02-11 19:34:45 -08:00
Mark McDowall
4849d1da10 Convert FilterBuilder types to TypeScript 2025-02-11 19:34:44 -08:00
Mark McDowall
8482f3da1a Convert Date utilties to TypeScript 2025-02-11 19:34:44 -08:00
Mark McDowall
782af002d6 Convert TableOptionsWrapper to TypeScript 2025-02-11 19:34:44 -08:00
Mark McDowall
8f6d9f3bf4 Convert Series Popovers to TypeScript 2025-02-11 19:34:44 -08:00
Mark McDowall
699120a8fd Convert Table to TypeScript 2025-02-11 19:34:43 -08:00
Mark McDowall
0fdeb05663 Convert Messages to TypeScript 2025-02-11 19:34:43 -08:00
Mark McDowall
7c64911b6b Remove withCurrentPage 2025-02-11 19:34:43 -08:00
Mark McDowall
3035521b93 Convert Missing to TypeScript 2025-02-11 19:34:42 -08:00
Mark McDowall
45c53bea86 Convert Cutoff Unmet to TypeScript 2025-02-11 19:34:42 -08:00
Mark McDowall
4c6d6b726e Convert Custom Format settings to TypeScript 2025-02-11 19:34:42 -08:00
Mark McDowall
1765feac03 Convert Notifications to TypeScript 2025-02-11 19:34:41 -08:00
Mark McDowall
92db4769be Convert Download Client settings to TypeScript 2025-02-11 19:34:41 -08:00
Mark McDowall
6838f068bc Improve typings in FormInputGroup 2025-02-11 19:34:41 -08:00
Mark McDowall
b218461678 Convert General Settings to TypeScript 2025-02-11 19:34:41 -08:00
Mark McDowall
10e3a237ef Convert ImportLists to TypeScript 2025-02-11 19:34:40 -08:00
Mark McDowall
6e008a8e85 Convert Indexer settings to TypeScript 2025-02-11 19:34:40 -08:00
Mark McDowall
27f81117ed Convert Media Management settings to TypeScript 2025-02-11 19:34:40 -08:00
Mark McDowall
839658a698 Convert MetadataSource to TypeScript 2025-02-11 19:34:40 -08:00
Mark McDowall
1bc1b080d1 Upgrade react-dnd and DnD Components to TypeScript 2025-02-11 19:34:39 -08:00
Mark McDowall
572bdc979c New: Quality limits are part of Quality Profile
Closes #613
2025-02-11 19:34:39 -08:00
Mark McDowall
cde0a31ff0 Convert Quality Settings to TypeScript 2025-02-11 19:34:39 -08:00
Mark McDowall
60529f0bac Convert Tags to TypeScript 2025-02-11 19:34:39 -08:00
Mark McDowall
5ed7780ed7 Convert MetadataSettings to TypeScript 2025-02-11 19:34:38 -08:00
Mark McDowall
89c8a10e0d Convert UI Settings to TypeScript 2025-02-11 19:34:38 -08:00
Mark McDowall
fd09ca6e71 Convert SettingsToolbar to TypeScript 2025-02-11 19:34:38 -08:00
Mark McDowall
95929dd9c2 Convert Log FIles to TypeScript 2025-02-11 19:34:38 -08:00
Mark McDowall
23bc6a157c Convert Log Events to TypeScript 2025-02-11 19:34:37 -08:00
Mark McDowall
756e985b66 Convert Backup and Restore to TypeScript 2025-02-11 19:34:37 -08:00
Mark McDowall
a2fd23c84d Convert Preview Rename to TypeScript 2025-02-11 19:34:37 -08:00
Mark McDowall
32ce09648c Convert SelectSeriesRow to TypeScript 2025-02-11 19:34:37 -08:00
Mark McDowall
20e1a8d116 Convert TagList components to TypeScript 2025-02-11 19:34:36 -08:00
Mark McDowall
12a1ef0387 Convert Menu components to TypeScript 2025-02-11 19:34:36 -08:00
Mark McDowall
2935d148a8 Convert ProviderFieldFormGroup to TypeScript 2025-02-11 19:34:36 -08:00
Mark McDowall
a90c13e86f Remove defaultProps from TypeScript components 2025-02-11 19:34:36 -08:00
Mark McDowall
9a7ddd751e Convert Filter components to TypeScript 2025-02-11 19:34:35 -08:00
Mark McDowall
a1d4bb5399 Convert Spinner button components to TypeScript 2025-02-11 19:34:35 -08:00
Mark McDowall
9d0acba000 Convert Modal components to TypeScript 2025-02-11 19:34:35 -08:00
Mark McDowall
ee1a0a1f71 useMeasure instead of Measure in TypeScript components 2025-02-11 19:34:34 -08:00
Mark McDowall
f35a27449d Convert Page components to TypeScript 2025-02-11 19:34:34 -08:00
Mark McDowall
4e65669c48 Bump version to 4.0.13 2025-02-11 19:25:11 -08:00
Weblate
fa38498db0 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Magnus5405 <magnus5405@outlook.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/da/
Translation: Servarr/Sonarr
2025-02-11 19:24:56 -08:00
Mark McDowall
3b024443c5 Fixed: Drop downs flickering in some cases
Closes #7608
2025-01-30 20:58:11 -08:00
Weblate
4ba9b21bb7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translation: Servarr/Sonarr
2025-01-30 20:57:59 -08:00
761 changed files with 23442 additions and 7868 deletions

View File

@@ -21,7 +21,7 @@ runs:
using: "composite"
steps:
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
- name: Setup Environment Variables
id: variables
@@ -170,7 +170,7 @@ runs:
framework="${{ inputs.framework }}"
runtime="${{ inputs.runtime }}"
cp test.sh "_tests/$framework/$runtime/publish"
cp scripts/test.sh "_tests/$framework/$runtime/publish"
rm -f _tests/$framework/$runtime/*.log.config

View File

@@ -4,6 +4,8 @@ description: Runs unit/integration tests
inputs:
use_postgres:
description: 'Whether postgres should be used for the database'
postgres-version:
description: 'Which postgres version should be used for the database'
os:
description: 'OS that the tests are running on'
required: true
@@ -27,16 +29,18 @@ runs:
using: 'composite'
steps:
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
- name: Setup Postgres
if: ${{ inputs.use_postgres }}
uses: ikalnytskyi/action-setup-postgres@v4
uses: ikalnytskyi/action-setup-postgres@v7
with:
postgres-version: ${{ inputs.postgres-version }}
- name: Setup Test Variables
shell: bash
run: |
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}" >> "$GITHUB_ENV"
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}${{ inputs.use_postgres && inputs.postgres-version && inputs.postgres-version }}" >> "$GITHUB_ENV"
- name: Setup Postgres Environment Variables
if: ${{ inputs.use_postgres }}
@@ -48,14 +52,14 @@ runs:
echo "Sonarr__Postgres__Password=postgres" >> "$GITHUB_ENV"
- name: Download Artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: ${{ inputs.artifact }}
path: _tests
- name: Download Binary Artifact
if: ${{ inputs.integration_tests }}
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: ${{ inputs.binary_artifact }}
path: _output

View File

@@ -33,7 +33,7 @@ jobs:
id: setup-dotnet
- name: Create openapi.json
run: ./docs.sh Linux x64
run: ./scripts/docs.sh Linux x64
- name: Commit API Docs Change
continue-on-error: true
@@ -50,3 +50,16 @@ jobs:
else
echo "No changes since last run"
fi
- name: Notify
if: failure()
uses: tsickert/discord-webhook@v6.0.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
username: "GitHub Actions"
avatar-url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
embed-title: "${{ github.workflow }}: Failure"
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
embed-description: |
Failed to update API docs
embed-color: "15158332"

View File

@@ -82,7 +82,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Build
uses: ./.github/actions/build
@@ -97,7 +97,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Volta
uses: volta-cli/action@v4
@@ -139,7 +139,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Test
uses: ./.github/actions/test
@@ -152,9 +152,13 @@ jobs:
unit_test_postgres:
needs: backend
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
postgres-version: [16, 17]
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Test
uses: ./.github/actions/test
@@ -164,6 +168,7 @@ jobs:
pattern: Sonarr.*.Test.dll
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
use_postgres: true
postgres-version: ${{ matrix.postgres-version }}
integration_test:
needs: [prepare, backend]
@@ -190,7 +195,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Test
uses: ./.github/actions/test

View File

@@ -82,4 +82,4 @@ Thank you to [<img src="https://resources.jetbrains.com/storage/products/company
### Licenses
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2024
- Copyright 2010-2025

113
distribution/debian/install.sh Normal file → Executable file
View File

@@ -6,6 +6,8 @@
### Version V1.0.1 2024-01-02 - StevieTV - remove UTF8-BOM
### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty
### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory
### Version V1.0.4 2025-04-05 - kaecyra - Allow user/group to be supplied via CLI, add unattended mode
### Version V1.0.5 2025-07-08 - bparkin1283 - use systemctl instead of service for stopping app
### Boilerplate Warning
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
@@ -16,8 +18,8 @@
#OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
#WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
scriptversion="1.0.3"
scriptdate="2024-01-06"
scriptversion="1.0.4"
scriptdate="2025-04-05"
set -euo pipefail
@@ -49,18 +51,106 @@ if [ "$installdir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ] || [ "$bindi
exit
fi
# Prompt User
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
show_help() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--user <name> What user will $app run under?
User will be created if it doesn't already exist.
--group <name> What group will $app run under?
Group will be created if it doesn't already exist.
-u Unattended mode
The installer will not prompt or pause, making it suitable for automated installations.
This option requires the use of --user and --group to supply those inputs for the script.
-h, --help Show this help message and exit
EOF
}
# Default values for command-line arguments
arg_user=""
arg_group=""
arg_unattended=false
# Parse command-line arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--user=*)
arg_user="${1#*=}"
shift
;;
--user)
if [[ -n "$2" && "$2" != -* ]]; then
arg_user="$2"
shift 2
else
echo "Error: --user requires a value." >&2
exit 1
fi
;;
--group=*)
arg_group="${1#*=}"
shift
;;
--group)
if [[ -n "$2" && "$2" != -* ]]; then
arg_group="$2"
shift 2
else
echo "Error: --group requires a value." >&2
exit 1
fi
;;
-u)
arg_unattended=true
shift
;;
-h|--help)
show_help
exit 0
;;
*)
echo "Unknown option: $1" >&2
echo "Use --help to see valid options." >&2
exit 1
;;
esac
done
# If unattended mode is requested, require user and group
if $arg_unattended; then
if [[ -z "$arg_user" || -z "$arg_group" ]]; then
echo "Error: --user and --group are required when using -u (unattended mode)." >&2
exit 1
fi
fi
# Prompt User if necessary
if [ -n "$arg_user" ]; then
app_uid="$arg_user"
else
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
fi
app_uid=$(echo "$app_uid" | tr -d ' ')
app_uid=${app_uid:-$app}
# Prompt Group
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
# Prompt Group if necessary
if [ -n "$arg_group" ]; then
app_guid="$arg_group"
else
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
fi
app_guid=$(echo "$app_guid" | tr -d ' ')
app_guid=${app_guid:-media}
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
if ! $arg_unattended; then
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
fi
# Create User / Group as needed
if [ "$app_guid" != "$app_uid" ]; then
@@ -78,11 +168,10 @@ if ! getent group "$app_guid" | grep -qw "$app_uid"; then
echo "Added User [$app_uid] to Group [$app_guid]"
fi
# Stop the App if running
if service --status-all | grep -Fq "$app"; then
systemctl stop "$app"
systemctl disable "$app".service
echo "Stopped existing $app"
# Stop and disable the App if running
if [ $(systemctl is-active "$app") = "active" ]; then
systemctl disable --now -q "$app"
echo "Stopped and disabled existing $app"
fi
# Create Appdata Directory

View File

@@ -7,9 +7,9 @@ cd /data/test
runTest()
{
bash test.sh Linux $1
bash scripts/test.sh Linux $1
cp TestResult.xml /data/_tests_results/TestResult_$1.xml
}
runTest Integration
runTest Unit
runTest Unit

View File

@@ -65,7 +65,7 @@ module.exports = (env) => {
output: {
path: distFolder,
publicPath: '/',
publicPath: 'auto',
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
sourceMapFilename: '[file].map'
},
@@ -176,7 +176,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: '3.39'
corejs: '3.42'
}
]
]

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { setQueueOptions } from 'Activity/Queue/queueOptionsStore';
import { SelectProvider } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -16,20 +16,8 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props';
import {
clearBlocklist,
fetchBlocklist,
gotoBlocklistPage,
removeBlocklistItems,
setBlocklistFilter,
setBlocklistSort,
setBlocklistTableOption,
} from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
@@ -43,27 +31,35 @@ import {
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import BlocklistFilterModal from './BlocklistFilterModal';
import {
setBlocklistOption,
useBlocklistOptions,
} from './blocklistOptionsStore';
import BlocklistRow from './BlocklistRow';
import useBlocklist, {
useFilters,
useRemoveBlocklistItems,
} from './useBlocklist';
function Blocklist() {
const requestCurrentPage = useCurrentPage();
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
records,
totalPages,
totalRecords,
isRemoving,
} = useSelector((state: AppState) => state.blocklist);
isFetching,
isFetched,
isLoading,
error,
page,
goToPage,
refetch,
} = useBlocklist();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useBlocklistOptions();
const filters = useFilters();
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
const isClearingBlocklistExecuting = useSelector(
@@ -82,28 +78,27 @@ function Blocklist() {
return getSelectedIds(selectedState);
}, [selectedState]);
const wasClearingBlocklistExecuting = usePrevious(
isClearingBlocklistExecuting
);
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
setSelectState({
type: value ? 'selectAll' : 'unselectAll',
items: records,
});
},
[items, setSelectState]
[records, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items,
items: records,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
[records, setSelectState]
);
const handleRemoveSelectedPress = useCallback(() => {
@@ -111,9 +106,9 @@ function Blocklist() {
}, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback(() => {
dispatch(removeBlocklistItems({ ids: selectedIds }));
removeBlocklistItems({ ids: selectedIds });
setIsConfirmRemoveModalOpen(false);
}, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]);
}, [selectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]);
const handleConfirmRemoveModalClose = useCallback(() => {
setIsConfirmRemoveModalOpen(false);
@@ -124,66 +119,46 @@ function Blocklist() {
}, [setIsConfirmClearModalOpen]);
const handleClearBlocklistConfirmed = useCallback(() => {
dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST }));
dispatch(
executeCommand({
name: commandNames.CLEAR_BLOCKLIST,
commandFinished: () => {
goToPage(1);
},
})
);
setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen, dispatch]);
}, [setIsConfirmClearModalOpen, goToPage, dispatch]);
const handleConfirmClearModalClose = useCallback(() => {
setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoBlocklistPage,
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
dispatch(setBlocklistFilter({ selectedFilterKey }));
setBlocklistOption('selectedFilterKey', selectedFilterKey);
},
[dispatch]
[]
);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setBlocklistSort({ sortKey }));
},
[dispatch]
);
const handleSortPress = useCallback((sortKey: string) => {
setBlocklistOption('sortKey', sortKey);
}, []);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setBlocklistTableOption(payload));
setQueueOptions(payload);
if (payload.pageSize) {
dispatch(gotoBlocklistPage({ page: 1 }));
goToPage(1);
}
},
[dispatch]
[goToPage]
);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchBlocklist());
} else {
dispatch(gotoBlocklistPage({ page: 1 }));
}
return () => {
dispatch(clearBlocklist());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchBlocklist());
refetch();
};
registerPagePopulator(repopulate);
@@ -191,16 +166,10 @@ function Blocklist() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
useEffect(() => {
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
dispatch(gotoBlocklistPage({ page: 1 }));
}
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
}, [refetch]);
return (
<SelectProvider items={items}>
<SelectProvider items={records}>
<PageContent title={translate('Blocklist')}>
<PageToolbar>
<PageToolbarSection>
@@ -215,7 +184,7 @@ function Blocklist() {
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!items.length}
isDisabled={!records.length}
isSpinning={isClearingBlocklistExecuting}
onPress={handleClearBlocklistPress}
/>
@@ -245,13 +214,13 @@ function Blocklist() {
</PageToolbar>
<PageContentBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isLoading && !isFetched ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
{!isLoading && !!error ? (
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
) : null}
{isPopulated && !error && !items.length ? (
{isFetched && !error && !records.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey === 'all'
? translate('NoBlocklistItems')
@@ -259,7 +228,7 @@ function Blocklist() {
</Alert>
) : null}
{isPopulated && !error && !!items.length ? (
{isFetched && !error && !!records.length ? (
<div>
<Table
selectAll={true}
@@ -274,7 +243,7 @@ function Blocklist() {
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
{records.map((item) => {
return (
<BlocklistRow
key={item.id}
@@ -292,11 +261,7 @@ function Blocklist() {
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
onPageSelect={goToPage}
/>
</div>
) : null}

View File

@@ -16,13 +16,19 @@ interface BlocklistDetailsModalProps {
protocol: DownloadProtocol;
indexer?: string;
message?: string;
source?: string;
onModalClose: () => void;
}
function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } =
props;
function BlocklistDetailsModal({
isOpen,
sourceTitle,
protocol,
indexer,
message,
source,
onModalClose,
}: BlocklistDetailsModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ModalContent onModalClose={onModalClose}>
@@ -50,6 +56,9 @@ function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
data={message}
/>
) : null}
{source ? (
<DescriptionListItem title={translate('Source')} data={source} />
) : null}
</DescriptionList>
</ModalBody>

View File

@@ -1,50 +1,26 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
function createBlocklistSelector() {
return createSelector(
(state: AppState) => state.blocklist.items,
(blocklistItems) => {
return blocklistItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.blocklist.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
import { setBlocklistOption } from './blocklistOptionsStore';
import useBlocklist, { FILTER_BUILDER } from './useBlocklist';
type BlocklistFilterModalProps = FilterModalProps<History>;
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
const sectionItems = useSelector(createBlocklistSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'blocklist';
const dispatch = useDispatch();
const { records } = useBlocklist();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setBlocklistFilter(payload));
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
setBlocklistOption('selectedFilterKey', selectedFilterKey);
},
[dispatch]
[]
);
return (
<FilterModal
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
sectionItems={records}
filterBuilderProps={FILTER_BUILDER}
customFilterType="blocklist"
dispatchSetFilter={dispatchSetFilter}
/>
);

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -12,11 +11,11 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds } from 'Helpers/Props';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { removeBlocklistItem } from 'Store/Actions/blocklistActions';
import Blocklist from 'typings/Blocklist';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import BlocklistDetailsModal from './BlocklistDetailsModal';
import { useRemoveBlocklistItem } from './useBlocklist';
import styles from './BlocklistRow.css';
interface BlocklistRowProps extends Blocklist {
@@ -25,25 +24,24 @@ interface BlocklistRowProps extends Blocklist {
onSelectedChange: (options: SelectStateInputProps) => void;
}
function BlocklistRow(props: BlocklistRowProps) {
const {
id,
seriesId,
sourceTitle,
languages,
quality,
customFormats,
date,
protocol,
indexer,
message,
isSelected,
columns,
onSelectedChange,
} = props;
function BlocklistRow({
id,
seriesId,
sourceTitle,
languages,
quality,
customFormats,
date,
protocol,
indexer,
message,
source,
isSelected,
columns,
onSelectedChange,
}: BlocklistRowProps) {
const series = useSeries(seriesId);
const dispatch = useDispatch();
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const handleDetailsPress = useCallback(() => {
@@ -55,8 +53,8 @@ function BlocklistRow(props: BlocklistRowProps) {
}, [setIsDetailsModalOpen]);
const handleRemovePress = useCallback(() => {
dispatch(removeBlocklistItem({ id }));
}, [id, dispatch]);
removeBlocklistItem();
}, [removeBlocklistItem]);
if (!series) {
return null;
@@ -139,6 +137,7 @@ function BlocklistRow(props: BlocklistRowProps) {
title={translate('RemoveFromBlocklist')}
name={icons.REMOVE}
kind={kinds.DANGER}
isSpinning={isRemoving}
onPress={handleRemovePress}
/>
</TableRowCell>
@@ -154,6 +153,7 @@ function BlocklistRow(props: BlocklistRowProps) {
protocol={protocol}
indexer={indexer}
message={message}
source={source}
onModalClose={handleDetailsModalClose}
/>
</TableRow>

View File

@@ -0,0 +1,71 @@
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import translate from 'Utilities/String/translate';
export type BlocklistOptions = PageableOptions;
const { useOptions, useOption, setOptions, setOption } =
createOptionsStore<BlocklistOptions>('blocklist_options', () => {
return {
pageSize: 20,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'series.sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true,
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isSortable: true,
isVisible: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false,
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true,
},
{
name: 'date',
label: () => translate('Date'),
isSortable: true,
isVisible: true,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useBlocklistOptions = useOptions;
export const setBlocklistOptions = setOptions;
export const useBlocklistOption = useOption;
export const setBlocklistOption = setOption;

View File

@@ -0,0 +1,116 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import Blocklist from 'typings/Blocklist';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { useBlocklistOptions } from './blocklistOptionsStore';
interface BulkBlocklistData {
ids: number[];
}
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
];
export const FILTER_BUILDER: FilterBuilderProp<Blocklist>[] = [
{
name: 'seriesIds',
label: () => translate('Series'),
type: 'equal',
valueType: filterBuilderValueTypes.SERIES,
},
{
name: 'protocols',
label: () => translate('Protocol'),
type: 'equal',
valueType: filterBuilderValueTypes.PROTOCOL,
},
];
const useBlocklist = () => {
const { page, goToPage } = usePage('blocklist');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useBlocklistOptions();
const customFilters = useSelector(
createCustomFiltersSelector('blocklist')
) as CustomFilter[];
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<Blocklist>({
path: '/blocklist',
page,
pageSize,
filters,
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
return {
...query,
goToPage,
page,
refetch,
};
};
export default useBlocklist;
export const useFilters = () => {
return FILTERS;
};
export const useRemoveBlocklistItem = (id: number) => {
const queryClient = useQueryClient();
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/blocklist/${id}`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
},
},
});
return {
removeBlocklistItem: mutate,
isRemoving: isPending,
};
};
export const useRemoveBlocklistItems = () => {
const queryClient = useQueryClient();
const { mutate, isPending } = useApiMutation<unknown, BulkBlocklistData>({
path: `/blocklist/bulk`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
},
},
});
return {
removeBlocklistItems: mutate,
isRemoving: isPending,
};
};

View File

@@ -174,7 +174,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
}
if (eventType === 'downloadFailed') {
const { message } = data as DownloadFailedHistory;
const { indexer, message, source } = data as DownloadFailedHistory;
return (
<DescriptionList>
@@ -188,9 +188,17 @@ 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}
{source ? (
<DescriptionListItem title={translate('Source')} data={source} />
) : null}
</DescriptionList>
);
}

View File

@@ -0,0 +1,113 @@
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Queue from 'typings/Queue';
interface EpisodeDetails {
episodeIds: number[];
}
interface SeriesDetails {
seriesId: number;
}
interface AllDetails {
all: boolean;
}
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
interface QueueDetailsProps {
children: ReactNode;
}
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
export default function QueueDetailsProvider({
children,
...filter
}: QueueDetailsProps & QueueDetailsFilter) {
const { data } = useApiQuery<Queue[]>({
path: '/queue/details',
queryParams: { ...filter },
queryOptions: {
enabled: Object.keys(filter).length > 0,
},
});
return (
<QueueDetailsContext.Provider value={data}>
{children}
</QueueDetailsContext.Provider>
);
}
export function useQueueItemForEpisode(episodeId: number) {
const queue = useContext(QueueDetailsContext);
return useMemo(() => {
return queue?.find((item) => item.episodeIds.includes(episodeId));
}, [episodeId, queue]);
}
export function useIsDownloadingEpisodes(episodeIds: number[]) {
const queue = useContext(QueueDetailsContext);
return useMemo(() => {
if (!queue) {
return false;
}
return queue.some((item) =>
item.episodeIds?.some((e) => episodeIds.includes(e))
);
}, [episodeIds, queue]);
}
export interface SeriesQueueDetails {
count: number;
episodesWithFiles: number;
}
export function useQueueDetailsForSeries(
seriesId: number,
seasonNumber?: number
) {
const queue = useContext(QueueDetailsContext);
return useMemo<SeriesQueueDetails>(() => {
if (!queue) {
return { count: 0, episodesWithFiles: 0 };
}
return queue.reduce<SeriesQueueDetails>(
(acc: SeriesQueueDetails, item) => {
if (
item.trackedDownloadState === 'imported' ||
item.seriesId !== seriesId
) {
return acc;
}
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
return acc;
}
acc.count++;
if (item.episodeHasFile) {
acc.episodesWithFiles++;
}
return acc;
},
{
count: 0,
episodesWithFiles: 0,
}
);
}, [seriesId, seasonNumber, queue]);
}
export const useQueueDetails = () => {
return useContext(QueueDetailsContext) ?? [];
};

View File

@@ -0,0 +1,76 @@
import React from 'react';
import Episode from 'Episode/Episode';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import Series from 'Series/Series';
import translate from 'Utilities/String/translate';
interface EpisodeCellContentProps {
episodes: Episode[];
isFullSeason: boolean;
seasonNumber?: number;
series?: Series;
}
export default function EpisodeCellContent({
episodes,
isFullSeason,
seasonNumber,
series,
}: EpisodeCellContentProps) {
if (episodes.length === 0) {
return '-';
}
if (isFullSeason && seasonNumber != null) {
return translate('SeasonNumberToken', { seasonNumber });
}
if (episodes.length === 1) {
const episode = episodes[0];
return (
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/>
);
}
const firstEpisode = episodes[0];
const lastEpisode = episodes[episodes.length - 1];
return (
<>
<SeasonEpisodeNumber
seasonNumber={firstEpisode.seasonNumber}
episodeNumber={firstEpisode.episodeNumber}
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
/>
{' - '}
<SeasonEpisodeNumber
seasonNumber={lastEpisode.seasonNumber}
episodeNumber={lastEpisode.episodeNumber}
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
/>
</>
);
}

View File

@@ -0,0 +1,13 @@
.multiple {
cursor: default;
}
.row {
display: flex;
}
.episodeNumber {
margin-right: 8px;
font-weight: bold;
cursor: default;
}

View File

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

View File

@@ -0,0 +1,66 @@
import React from 'react';
import Popover from 'Components/Tooltip/Popover';
import Episode from 'Episode/Episode';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import Series from 'Series/Series';
import translate from 'Utilities/String/translate';
import styles from './EpisodeTitleCellContent.css';
interface EpisodeTitleCellContentProps {
episodes: Episode[];
series?: Series;
}
export default function EpisodeTitleCellContent({
episodes,
series,
}: EpisodeTitleCellContentProps) {
if (episodes.length === 0 || !series) {
return '-';
}
if (episodes.length === 1) {
const episode = episodes[0];
return (
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
);
}
return (
<Popover
anchor={
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
}
title={translate('EpisodeTitles')}
body={
<>
{episodes.map((episode) => {
return (
<div key={episode.id} className={styles.row}>
<div className={styles.episodeNumber}>
{episode.episodeNumber}
</div>
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
</div>
);
})}
</>
}
position="right"
/>
);
}

View File

@@ -7,7 +7,6 @@ import React, {
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -22,28 +21,15 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import {
clearQueue,
fetchQueue,
gotoQueuePage,
grabQueueItems,
removeQueueItems,
setQueueFilter,
setQueueSort,
setQueueTableOption,
} from 'Store/Actions/queueActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import QueueItem from 'typings/Queue';
import { TableOptionsChangePayload } from 'typings/Table';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import {
@@ -54,33 +40,45 @@ import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import QueueFilterModal from './QueueFilterModal';
import QueueOptions from './QueueOptions';
import {
setQueueOption,
setQueueOptions,
useQueueOptions,
} from './queueOptionsStore';
import QueueRow from './QueueRow';
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
import createQueueStatusSelector from './Status/createQueueStatusSelector';
import RemoveQueueItemModal from './RemoveQueueItemModal';
import useQueueStatus from './Status/useQueueStatus';
import useQueue, {
useFilters,
useGrabQueueItems,
useRemoveQueueItems,
} from './useQueue';
function Queue() {
const requestCurrentPage = useCurrentPage();
const dispatch = useDispatch();
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
records,
totalPages,
totalRecords,
isGrabbing,
isRemoving,
} = useSelector((state: AppState) => state.queue.paged);
error,
isFetching,
isFetched,
isLoading,
page,
goToPage,
refetch,
} = useQueue();
const { count } = useSelector(createQueueStatusSelector());
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useQueueOptions();
const filters = useFilters();
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
const { count } = useQueueStatus();
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('queue'));
@@ -100,41 +98,46 @@ function Queue() {
}, [selectedState]);
const isPendingSelected = useMemo(() => {
return items.some((item) => {
return records.some((item) => {
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
});
}, [items, selectedIds]);
}, [records, selectedIds]);
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
useState(false);
const isRefreshing =
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
const isAllPopulated =
isPopulated &&
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
isFetched &&
(isEpisodesPopulated ||
!records.length ||
records.every((e) => !e.episodeIds?.length));
const hasError = error || episodesError;
const selectedCount = selectedIds.length;
const disableSelectedActions = selectedCount === 0;
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
setSelectState({
type: value ? 'selectAll' : 'unselectAll',
items: records,
});
},
[items, setSelectState]
[records, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items,
items: records,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
[records, setSelectState]
);
const handleRefreshPress = useCallback(() => {
@@ -150,93 +153,60 @@ function Queue() {
}, []);
const handleGrabSelectedPress = useCallback(() => {
dispatch(grabQueueItems({ ids: selectedIds }));
}, [selectedIds, dispatch]);
grabQueueItems({ ids: selectedIds });
}, [selectedIds, grabQueueItems]);
const handleRemoveSelectedPress = useCallback(() => {
shouldBlockRefresh.current = true;
setIsConfirmRemoveModalOpen(true);
}, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback(
(payload: RemovePressProps) => {
shouldBlockRefresh.current = false;
dispatch(removeQueueItems({ ids: selectedIds, ...payload }));
setIsConfirmRemoveModalOpen(false);
},
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
);
const handleRemoveSelectedConfirmed = useCallback(() => {
shouldBlockRefresh.current = false;
removeQueueItems({ ids: selectedIds });
setIsConfirmRemoveModalOpen(false);
}, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]);
const handleConfirmRemoveModalClose = useCallback(() => {
shouldBlockRefresh.current = false;
setIsConfirmRemoveModalOpen(false);
}, [setIsConfirmRemoveModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoQueuePage,
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
dispatch(setQueueFilter({ selectedFilterKey }));
setQueueOption('selectedFilterKey', selectedFilterKey);
},
[dispatch]
[]
);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setQueueSort({ sortKey }));
},
[dispatch]
);
const handleSortPress = useCallback((sortKey: string) => {
setQueueOption('sortKey', sortKey);
}, []);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setQueueTableOption(payload));
setQueueOptions(payload);
if (payload.pageSize) {
dispatch(gotoQueuePage({ page: 1 }));
goToPage(1);
}
},
[dispatch]
[goToPage]
);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchQueue());
} else {
dispatch(gotoQueuePage({ page: 1 }));
}
return () => {
dispatch(clearQueue());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
items,
'episodeId'
);
const episodeIds = selectUniqueIds(records, 'episodeIds');
if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds }));
} else {
dispatch(clearEpisodes());
}
}, [items, dispatch]);
}, [records, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchQueue());
refetch();
};
registerPagePopulator(repopulate);
@@ -244,7 +214,7 @@ function Queue() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
}, [refetch]);
if (!shouldBlockRefresh.current) {
currentQueue.current = (
@@ -255,7 +225,7 @@ function Queue() {
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
) : null}
{isAllPopulated && !hasError && !items.length ? (
{isAllPopulated && !hasError && !records.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey !== 'all' && count > 0
? translate('QueueFilterHasNoItems')
@@ -263,7 +233,7 @@ function Queue() {
</Alert>
) : null}
{isAllPopulated && !hasError && !!items.length ? (
{isAllPopulated && !hasError && !!records.length ? (
<div>
<Table
selectAll={true}
@@ -279,11 +249,10 @@ function Queue() {
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
{records.map((item) => {
return (
<QueueRow
key={item.id}
episodeId={item.episodeId}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
@@ -302,11 +271,7 @@ function Queue() {
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
onPageSelect={goToPage}
/>
</div>
) : null}
@@ -377,7 +342,7 @@ function Queue() {
canChangeCategory={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
const item = records.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory);
})
@@ -385,7 +350,7 @@ function Queue() {
canIgnore={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
const item = records.find((i) => i.id === id);
return !!(item && item.seriesId && item.episodeId);
})
@@ -393,7 +358,7 @@ function Queue() {
isPending={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
const item = records.find((i) => i.id === id);
if (!item) {
return false;

View File

@@ -14,7 +14,7 @@ import styles from './QueueDetails.css';
interface QueueDetailsProps {
title: string;
size: number;
sizeleft: number;
sizeLeft: number;
estimatedCompletionTime?: string;
status: string;
trackedDownloadState?: QueueTrackedDownloadState;
@@ -28,7 +28,7 @@ function QueueDetails(props: QueueDetailsProps) {
const {
title,
size,
sizeleft,
sizeLeft,
status,
trackedDownloadState = 'downloading',
trackedDownloadStatus = 'ok',
@@ -37,7 +37,7 @@ function QueueDetails(props: QueueDetailsProps) {
progressBar,
} = props;
const progress = 100 - (sizeleft / size) * 100;
const progress = 100 - (sizeLeft / size) * 100;
const isDownloading = status === 'downloading';
const isPaused = status === 'paused';
const hasWarning = trackedDownloadStatus === 'warning';
@@ -61,7 +61,7 @@ function QueueDetails(props: QueueDetailsProps) {
anchor={progressBar!}
title={`${state} - ${progress.toFixed(1)}%`}
body={<div>{title}</div>}
position={tooltipPositions.LEFT}
position="bottom-start"
/>
);
}

View File

@@ -1,50 +1,26 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setQueueFilter } from 'Store/Actions/queueActions';
function createQueueSelector() {
return createSelector(
(state: AppState) => state.queue.paged.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.queue.paged.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
import { setQueueOption } from './queueOptionsStore';
import useQueue, { FILTER_BUILDER } from './useQueue';
type QueueFilterModalProps = FilterModalProps<History>;
export default function QueueFilterModal(props: QueueFilterModalProps) {
const sectionItems = useSelector(createQueueSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'queue';
const dispatch = useDispatch();
const { records } = useQueue();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setQueueFilter(payload));
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
setQueueOption('selectedFilterKey', selectedFilterKey);
},
[dispatch]
[]
);
return (
<FilterModal
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
sectionItems={records}
filterBuilderProps={FILTER_BUILDER}
customFilterType="queue"
dispatchSetFilter={dispatchSetFilter}
/>
);

View File

@@ -1,33 +1,30 @@
import React, { useCallback } 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 { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
import { inputTypes } from 'Helpers/Props';
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import {
QueueOptions as QueueOptionsType,
setQueueOption,
useQueueOption,
} from './queueOptionsStore';
import useQueue from './useQueue';
function QueueOptions() {
const dispatch = useDispatch();
const { includeUnknownSeriesItems } = useSelector(
(state: AppState) => state.queue.options
);
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
const { goToPage } = useQueue();
const handleOptionChange = useCallback(
({ name, value }: InputChanged<boolean>) => {
dispatch(
setQueueOption({
[name]: value,
})
);
({ name, value }: OptionChanged<QueueOptionsType>) => {
setQueueOption(name, value);
if (name === 'includeUnknownSeriesItems') {
dispatch(gotoQueuePage({ page: 1 }));
goToPage(1);
}
},
[dispatch]
[goToPage]
);
return (
@@ -39,6 +36,7 @@ function QueueOptions() {
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleOptionChange}
/>
</FormGroup>

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import { Error } from 'App/State/AppSectionState';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
@@ -15,16 +14,13 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import useEpisode from 'Episode/useEpisode';
import useEpisodes from 'Episode/useEpisodes';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CustomFormat from 'typings/CustomFormat';
import { SelectStateInputProps } from 'typings/props';
@@ -36,15 +32,18 @@ import {
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import EpisodeCellContent from './EpisodeCellContent';
import EpisodeTitleCellContent from './EpisodeTitleCellContent';
import QueueStatusCell from './QueueStatusCell';
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
import TimeleftCell from './TimeleftCell';
import RemoveQueueItemModal from './RemoveQueueItemModal';
import TimeLeftCell from './TimeLeftCell';
import { useGrabQueueItem, useRemoveQueueItem } from './useQueue';
import styles from './QueueRow.css';
interface QueueRowProps {
id: number;
seriesId?: number;
episodeId?: number;
episodeIds: number[];
downloadId?: string;
title: string;
status: string;
@@ -58,16 +57,16 @@ interface QueueRowProps {
customFormatScore: number;
protocol: DownloadProtocol;
indexer?: string;
isFullSeason: boolean;
seasonNumbers: number[];
outputPath?: string;
downloadClient?: string;
downloadClientHasPostImportCategory?: boolean;
estimatedCompletionTime?: string;
added?: string;
timeleft?: string;
timeLeft?: string;
size: number;
sizeleft: number;
isGrabbing?: boolean;
grabError?: Error;
sizeLeft: number;
isRemoving?: boolean;
isSelected?: boolean;
columns: Column[];
@@ -79,7 +78,7 @@ function QueueRow(props: QueueRowProps) {
const {
id,
seriesId,
episodeId,
episodeIds,
downloadId,
title,
status,
@@ -97,25 +96,25 @@ function QueueRow(props: QueueRowProps) {
downloadClient,
downloadClientHasPostImportCategory,
estimatedCompletionTime,
isFullSeason,
seasonNumbers,
added,
timeleft,
timeLeft,
size,
sizeleft,
isGrabbing = false,
grabError,
isRemoving = false,
sizeLeft,
isSelected,
columns,
onSelectedChange,
onQueueRowModalOpenOrClose,
} = props;
const dispatch = useDispatch();
const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes');
const episodes = useEpisodes(episodeIds, 'episodes');
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
useState(false);
@@ -124,8 +123,8 @@ function QueueRow(props: QueueRowProps) {
useState(false);
const handleGrabPress = useCallback(() => {
dispatch(grabQueueItem({ id }));
}, [id, dispatch]);
grabQueueItem();
}, [grabQueueItem]);
const handleInteractiveImportPress = useCallback(() => {
onQueueRowModalOpenOrClose(true);
@@ -142,21 +141,22 @@ function QueueRow(props: QueueRowProps) {
setIsRemoveQueueItemModalOpen(true);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const handleRemoveQueueItemModalConfirmed = useCallback(
(payload: RemovePressProps) => {
onQueueRowModalOpenOrClose(false);
dispatch(removeQueueItem({ id, ...payload }));
setIsRemoveQueueItemModalOpen(false);
},
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch]
);
const handleRemoveQueueItemModalConfirmed = useCallback(() => {
onQueueRowModalOpenOrClose(false);
removeQueueItem();
setIsRemoveQueueItemModalOpen(false);
}, [
setIsRemoveQueueItemModalOpen,
removeQueueItem,
onQueueRowModalOpenOrClose,
]);
const handleRemoveQueueItemModalClose = useCallback(() => {
onQueueRowModalOpenOrClose(false);
setIsRemoveQueueItemModalOpen(false);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const progress = 100 - (sizeleft / size) * 100;
const progress = 100 - (sizeLeft / size) * 100;
const showInteractiveImport =
status === 'completed' && trackedDownloadStatus === 'warning';
const isPending =
@@ -209,23 +209,12 @@ function QueueRow(props: QueueRowProps) {
if (name === 'episode') {
return (
<TableRowCell key={name}>
{episode ? (
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={
episode.sceneAbsoluteEpisodeNumber
}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/>
) : (
'-'
)}
<EpisodeCellContent
episodes={episodes}
isFullSeason={isFullSeason}
seasonNumber={seasonNumbers[0]}
series={series}
/>
</TableRowCell>
);
}
@@ -233,27 +222,37 @@ function QueueRow(props: QueueRowProps) {
if (name === 'episodes.title') {
return (
<TableRowCell key={name}>
{series && episode ? (
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
) : (
'-'
)}
<EpisodeTitleCellContent episodes={episodes} series={series} />
</TableRowCell>
);
}
if (name === 'episodes.airDateUtc') {
if (episode) {
return <RelativeDateCell key={name} date={episode.airDateUtc} />;
if (episodes.length === 0) {
return <TableRowCell key={name}>-</TableRowCell>;
}
return <TableRowCell key={name}>-</TableRowCell>;
if (episodes.length === 1) {
return (
<RelativeDateCell key={name} date={episodes[0].airDateUtc} />
);
}
return (
<TableRowCell key={name}>
<RelativeDateCell
key={name}
component="span"
date={episodes[0].airDateUtc}
/>
{' - '}
<RelativeDateCell
key={name}
component="span"
date={episodes[episodes.length - 1].airDateUtc}
/>
</TableRowCell>
);
}
if (name === 'languages') {
@@ -325,13 +324,13 @@ function QueueRow(props: QueueRowProps) {
if (name === 'estimatedCompletionTime') {
return (
<TimeleftCell
<TimeLeftCell
key={name}
status={status}
estimatedCompletionTime={estimatedCompletionTime}
timeleft={timeleft}
timeLeft={timeLeft}
size={size}
sizeleft={sizeleft}
sizeLeft={sizeLeft}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}

View File

@@ -90,7 +90,7 @@ function QueueStatus(props: QueueStatusProps) {
if (trackedDownloadState === 'importing') {
title += ` - ${translate('Importing')}`;
iconKind = kinds.PURPLE;
iconKind = kinds.PRIMARY;
}
if (trackedDownloadState === 'failedPending') {

View File

@@ -1,24 +1,24 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
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 { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import {
QueueOptions,
setQueueOption,
useQueueOption,
} from './queueOptionsStore';
import styles from './RemoveQueueItemModal.css';
export interface RemovePressProps {
remove: boolean;
changeCategory: boolean;
blocklist: boolean;
skipRedownload: boolean;
}
interface RemoveQueueItemModalProps {
isOpen: boolean;
sourceTitle?: string;
@@ -26,16 +26,10 @@ interface RemoveQueueItemModalProps {
canIgnore: boolean;
isPending: boolean;
selectedCount?: number;
onRemovePress(props: RemovePressProps): void;
onRemovePress(): void;
onModalClose: () => void;
}
type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
type BlocklistMethod =
| 'doNotBlocklist'
| 'blocklistAndSearch'
| 'blocklistOnly';
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
const {
isOpen,
@@ -49,11 +43,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
} = props;
const multipleSelected = selectedCount && selectedCount > 1;
const [removalMethod, setRemovalMethod] =
useState<RemovalMethod>('removeFromClient');
const [blocklistMethod, setBlocklistMethod] =
useState<BlocklistMethod>('doNotBlocklist');
const { removalMethod, blocklistMethod } = useQueueOption('removalOptions');
const { title, message } = useMemo(() => {
if (!selectedCount) {
@@ -79,7 +69,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
}, [sourceTitle, selectedCount]);
const removalMethodOptions = useMemo(() => {
return [
const options: EnhancedSelectInputValue<string>[] = [
{
key: 'removeFromClient',
value: translate('RemoveFromDownloadClient'),
@@ -106,10 +96,12 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
: translate('IgnoreDownloadHint'),
},
];
return options;
}, [canChangeCategory, canIgnore, multipleSelected]);
const blocklistMethodOptions = useMemo(() => {
return [
const options: EnhancedSelectInputValue<string>[] = [
{
key: 'doNotBlocklist',
value: translate('DoNotBlocklist'),
@@ -131,46 +123,28 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
: translate('BlocklistOnlyHint'),
},
];
return options;
}, [isPending, multipleSelected]);
const handleRemovalMethodChange = useCallback(
({ value }: { value: RemovalMethod }) => {
setRemovalMethod(value);
const handleRemovalOptionInputChange = useCallback(
({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => {
setQueueOption('removalOptions', {
removalMethod,
blocklistMethod,
[name]: value,
});
},
[setRemovalMethod]
);
const handleBlocklistMethodChange = useCallback(
({ value }: { value: BlocklistMethod }) => {
setBlocklistMethod(value);
},
[setBlocklistMethod]
[removalMethod, blocklistMethod]
);
const handleConfirmRemove = useCallback(() => {
onRemovePress({
remove: removalMethod === 'removeFromClient',
changeCategory: removalMethod === 'changeCategory',
blocklist: blocklistMethod !== 'doNotBlocklist',
skipRedownload: blocklistMethod === 'blocklistOnly',
});
setRemovalMethod('removeFromClient');
setBlocklistMethod('doNotBlocklist');
}, [
removalMethod,
blocklistMethod,
setRemovalMethod,
setBlocklistMethod,
onRemovePress,
]);
onRemovePress();
}, [onRemovePress]);
const handleModalClose = useCallback(() => {
setRemovalMethod('removeFromClient');
setBlocklistMethod('doNotBlocklist');
onModalClose();
}, [setRemovalMethod, setBlocklistMethod, onModalClose]);
}, [onModalClose]);
return (
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={handleModalClose}>
@@ -193,7 +167,8 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
helpTextWarning={translate(
'RemoveQueueItemRemovalMethodHelpTextWarning'
)}
onChange={handleRemovalMethodChange}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleRemovalOptionInputChange}
/>
</FormGroup>
)}
@@ -211,7 +186,8 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
value={blocklistMethod}
values={blocklistMethodOptions}
helpText={translate('BlocklistReleaseHelpText')}
onChange={handleBlocklistMethodChange}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleRemovalOptionInputChange}
/>
</FormGroup>
</ModalBody>

View File

@@ -1,33 +1,9 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import React from 'react';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { fetchQueueStatus } from 'Store/Actions/queueActions';
import createQueueStatusSelector from './createQueueStatusSelector';
import useQueueStatus from './useQueueStatus';
function QueueStatus() {
const dispatch = useDispatch();
const { isConnected, isReconnecting } = useSelector(
(state: AppState) => state.app
);
const { isPopulated, count, errors, warnings } = useSelector(
createQueueStatusSelector()
);
const wasReconnecting = usePrevious(isReconnecting);
useEffect(() => {
if (!isPopulated) {
dispatch(fetchQueueStatus());
}
}, [isPopulated, dispatch]);
useEffect(() => {
if (isConnected && wasReconnecting) {
dispatch(fetchQueueStatus());
}
}, [isConnected, wasReconnecting, dispatch]);
const { errors, warnings, count } = useQueueStatus();
return (
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />

View File

@@ -1,32 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createQueueStatusSelector() {
return createSelector(
(state: AppState) => state.queue.status.isPopulated,
(state: AppState) => state.queue.status.item,
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
(isPopulated, status, includeUnknownSeriesItems) => {
const {
errors,
warnings,
unknownErrors,
unknownWarnings,
count,
totalCount,
} = status;
return {
...status,
isPopulated,
count: includeUnknownSeriesItems ? totalCount : count,
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
warnings: includeUnknownSeriesItems
? warnings || unknownWarnings
: warnings,
};
}
);
}
export default createQueueStatusSelector;

View File

@@ -0,0 +1,54 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import { useQueueOption } from '../queueOptionsStore';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export default function useQueueStatus() {
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
const { data } = useApiQuery<QueueStatus>({
path: '/queue/status',
queryParams: {
includeUnknownSeriesItems,
},
});
if (!data) {
return {
count: 0,
errors: false,
warnings: false,
};
}
const {
errors,
warnings,
unknownErrors,
unknownWarnings,
count,
totalCount,
} = data;
if (includeUnknownSeriesItems) {
return {
count: totalCount,
errors: errors || unknownErrors,
warnings: warnings || unknownWarnings,
};
}
return {
count,
errors,
warnings,
};
}

View File

@@ -1,4 +1,4 @@
.timeleft {
.timeLeft {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px;

View File

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

View File

@@ -8,26 +8,26 @@ import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './TimeleftCell.css';
import styles from './TimeLeftCell.css';
interface TimeleftCellProps {
interface TimeLeftCellProps {
estimatedCompletionTime?: string;
timeleft?: string;
timeLeft?: string;
status: string;
size: number;
sizeleft: number;
sizeLeft: number;
showRelativeDates: boolean;
shortDateFormat: string;
timeFormat: string;
}
function TimeleftCell(props: TimeleftCellProps) {
function TimeLeftCell(props: TimeLeftCellProps) {
const {
estimatedCompletionTime,
timeleft,
timeLeft,
status,
size,
sizeleft,
sizeLeft,
showRelativeDates,
shortDateFormat,
timeFormat,
@@ -44,7 +44,7 @@ function TimeleftCell(props: TimeleftCellProps) {
});
return (
<TableRowCell className={styles.timeleft}>
<TableRowCell className={styles.timeLeft}>
<Tooltip
anchor={<Icon name={icons.INFO} />}
tooltip={translate('DelayingDownloadUntil', { date, time })}
@@ -66,7 +66,7 @@ function TimeleftCell(props: TimeleftCellProps) {
});
return (
<TableRowCell className={styles.timeleft}>
<TableRowCell className={styles.timeLeft}>
<Tooltip
anchor={<Icon name={icons.INFO} />}
tooltip={translate('RetryingDownloadOn', { date, time })}
@@ -77,21 +77,21 @@ function TimeleftCell(props: TimeleftCellProps) {
);
}
if (!timeleft || status === 'completed' || status === 'failed') {
return <TableRowCell className={styles.timeleft}>-</TableRowCell>;
if (!timeLeft || status === 'completed' || status === 'failed') {
return <TableRowCell className={styles.timeLeft}>-</TableRowCell>;
}
const totalSize = formatBytes(size);
const remainingSize = formatBytes(sizeleft);
const remainingSize = formatBytes(sizeLeft);
return (
<TableRowCell
className={styles.timeleft}
className={styles.timeLeft}
title={`${remainingSize} / ${totalSize}`}
>
{formatTimeSpan(timeleft)}
{formatTimeSpan(timeLeft)}
</TableRowCell>
);
}
export default TimeleftCell;
export default TimeLeftCell;

View File

@@ -0,0 +1,160 @@
import React from 'react';
import Icon from 'Components/Icon';
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
interface QueueRemovalOptions {
removalMethod: 'changeCategory' | 'ignore' | 'removeFromClient';
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
}
export interface QueueOptions extends PageableOptions {
includeUnknownSeriesItems: boolean;
removalOptions: QueueRemovalOptions;
}
const { useOptions, useOption, setOptions, setOption } =
createOptionsStore<QueueOptions>('queue_options', () => {
return {
includeUnknownSeriesItems: true,
pageSize: 20,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'status',
label: '',
columnLabel: () => translate('Status'),
isSortable: true,
isVisible: true,
isModifiable: false,
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true,
},
{
name: 'episode',
label: () => translate('EpisodeMaybePlural'),
isSortable: true,
isVisible: true,
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitleMaybePlural'),
isSortable: true,
isVisible: true,
},
{
name: 'episodes.airDateUtc',
label: () => translate('EpisodeAirDate'),
isSortable: true,
isVisible: false,
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: true,
isVisible: false,
},
{
name: 'quality',
label: () => translate('Quality'),
isSortable: true,
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true,
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isVisible: false,
},
{
name: 'protocol',
label: () => translate('Protocol'),
isSortable: true,
isVisible: false,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false,
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
isSortable: true,
isVisible: false,
},
{
name: 'title',
label: () => translate('ReleaseTitle'),
isSortable: true,
isVisible: false,
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: false,
},
{
name: 'outputPath',
label: () => translate('OutputPath'),
isSortable: false,
isVisible: false,
},
{
name: 'estimatedCompletionTime',
label: () => translate('TimeLeft'),
isSortable: true,
isVisible: true,
},
{
name: 'added',
label: () => translate('Added'),
isSortable: true,
isVisible: false,
},
{
name: 'progress',
label: () => translate('Progress'),
isSortable: true,
isVisible: true,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
removalOptions: {
removalMethod: 'removeFromClient',
blocklistMethod: 'doNotBlocklist',
},
};
});
export const useQueueOptions = useOptions;
export const setQueueOptions = setOptions;
export const useQueueOption = useOption;
export const setQueueOption = setOption;

View File

@@ -0,0 +1,203 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import Queue from 'typings/Queue';
import getQueryString from 'Utilities/Fetch/getQueryString';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { useQueueOptions } from './queueOptionsStore';
interface BulkQueueData {
ids: number[];
}
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
];
export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
{
name: 'seriesIds',
label: () => translate('Series'),
type: 'equal',
valueType: filterBuilderValueTypes.SERIES,
},
{
name: 'quality',
label: () => translate('Quality'),
type: 'equal',
valueType: filterBuilderValueTypes.QUALITY,
},
{
name: 'languages',
label: () => translate('Languages'),
type: 'contains',
valueType: filterBuilderValueTypes.LANGUAGE,
},
{
name: 'protocol',
label: () => translate('Protocol'),
type: 'equal',
valueType: filterBuilderValueTypes.PROTOCOL,
},
{
name: 'status',
label: () => translate('Status'),
type: 'equal',
valueType: filterBuilderValueTypes.QUEUE_STATUS,
},
];
const useQueue = () => {
const { page, goToPage } = usePage('queue');
const {
includeUnknownSeriesItems,
pageSize,
selectedFilterKey,
sortKey,
sortDirection,
} = useQueueOptions();
const customFilters = useSelector(
createCustomFiltersSelector('queue')
) as CustomFilter[];
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<Queue>({
path: '/queue',
page,
pageSize,
filters,
queryParams: {
includeUnknownSeriesItems,
},
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
return {
...query,
goToPage,
page,
refetch,
};
};
export default useQueue;
export const useFilters = () => {
return FILTERS;
};
const useRemovalOptions = () => {
const { removalOptions } = useQueueOptions();
return {
remove: removalOptions.removalMethod === 'removeFromClient',
changeCategory: removalOptions.removalMethod === 'changeCategory',
blocklist: removalOptions.blocklistMethod !== 'doNotBlocklist',
skipRedownload: removalOptions.blocklistMethod === 'blocklistOnly',
};
};
export const useRemoveQueueItem = (id: number) => {
const queryClient = useQueryClient();
const removalOptions = useRemovalOptions();
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/queue/${id}${getQueryString(removalOptions)}`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
},
});
return {
removeQueueItem: mutate,
isRemoving: isPending,
};
};
export const useRemoveQueueItems = () => {
const queryClient = useQueryClient();
const removalOptions = useRemovalOptions();
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
path: `/queue/bulk${getQueryString(removalOptions)}`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
},
});
return {
removeQueueItems: mutate,
isRemoving: isPending,
};
};
export const useGrabQueueItem = (id: number) => {
const queryClient = useQueryClient();
const [grabError, setGrabError] = useState<string | null>(null);
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/queue/grab/${id}`,
method: 'POST',
mutationOptions: {
onMutate: () => {
setGrabError(null);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
onError: () => {
setGrabError('Error grabbing queue item');
},
},
});
return {
grabQueueItem: mutate,
isGrabbing: isPending,
grabError,
};
};
export const useGrabQueueItems = () => {
const queryClient = useQueryClient();
// Explicitly define the types for the mutation so we can pass in no arguments to mutate as expected.
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
path: '/queue/grab/bulk',
method: 'POST',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
},
});
return {
grabQueueItems: mutate,
isGrabbing: isPending,
};
};

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AddSeries } from 'App/State/AddSeriesAppState';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
@@ -10,7 +9,6 @@ import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import useDebounce from 'Helpers/Hooks/useDebounce';
import useQueryParams from 'Helpers/Hooks/useQueryParams';
import { icons, kinds } from 'Helpers/Props';
@@ -18,6 +16,7 @@ import { InputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
import { useLookupSeries } from './useAddSeries';
import styles from './AddNewSeries.css';
function AddNewSeries() {
@@ -48,12 +47,7 @@ function AddNewSeries() {
isFetching: isFetchingApi,
error,
data = [],
} = useApiQuery<AddSeries[]>({
path: `/series/lookup?term=${query}`,
queryOptions: {
enabled: !!query,
},
});
} = useLookupSeries(query);
useEffect(() => {
setIsFetching(isFetchingApi);
@@ -103,7 +97,9 @@ function AddNewSeries() {
{!isFetching && !error && !!data.length ? (
<div className={styles.searchResults}>
{data.map((item) => {
return <AddNewSeriesSearchResult key={item.tvdbId} {...item} />;
return (
<AddNewSeriesSearchResult key={item.tvdbId} series={item} />
);
})}
</div>
) : null}

View File

@@ -1,9 +1,13 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import {
AddSeriesOptions,
setAddSeriesOption,
useAddSeriesOptions,
} from 'AddSeries/addSeriesOptionsStore';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
import { AddSeries } from 'App/State/AddSeriesAppState';
import AppState from 'App/State/AppState';
import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@@ -17,46 +21,39 @@ 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 { SeriesType } from 'Series/Series';
import SeriesPoster from 'Series/SeriesPoster';
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import useIsWindows from 'System/useIsWindows';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import { useAddSeries } from './useAddSeries';
import styles from './AddNewSeriesModalContent.css';
export interface AddNewSeriesModalContentProps
extends Pick<
AddSeries,
'tvdbId' | 'title' | 'year' | 'overview' | 'images' | 'folder'
> {
initialSeriesType: string;
export interface AddNewSeriesModalContentProps {
series: AddSeries;
initialSeriesType: SeriesType;
onModalClose: () => void;
}
function AddNewSeriesModalContent({
tvdbId,
title,
year,
overview,
images,
folder,
series,
initialSeriesType,
onModalClose,
}: AddNewSeriesModalContentProps) {
const dispatch = useDispatch();
const { isAdding, addError, defaults } = useSelector(
(state: AppState) => state.addSeries
);
const { title, year, overview, images, folder } = series;
const options = useAddSeriesOptions();
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isWindows = useIsWindows();
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(defaults, {}, addError);
}, [defaults, addError]);
const { isAdding, addError, addSeries } = useAddSeries();
const [seriesType, setSeriesType] = useState(
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(options, {}, addError);
}, [options, addError]);
const [seriesType, setSeriesType] = useState<SeriesType>(
initialSeriesType === 'standard'
? settings.seriesType.value
: initialSeriesType
@@ -74,35 +71,33 @@ function AddNewSeriesModalContent({
} = settings;
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
dispatch(setAddSeriesDefault({ [name]: value }));
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
setAddSeriesOption(name as keyof AddSeriesOptions, value);
},
[dispatch]
[]
);
const handleQualityProfileIdChange = useCallback(
({ value }: InputChanged<string | number>) => {
dispatch(setAddSeriesDefault({ qualityProfileId: value }));
setAddSeriesOption('qualityProfileId', value as number);
},
[dispatch]
[]
);
const handleAddSeriesPress = useCallback(() => {
dispatch(
addSeries({
tvdbId,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value,
})
);
addSeries({
...series,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value,
});
}, [
tvdbId,
series,
seriesType,
rootFolderPath,
monitor,
@@ -111,7 +106,7 @@ function AddNewSeriesModalContent({
searchForMissingEpisodes,
searchForCutoffUnmetEpisodes,
tags,
dispatch,
addSeries,
]);
useEffect(() => {

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { AddSeries } from 'App/State/AddSeriesAppState';
import AddSeries from 'AddSeries/AddSeries';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
@@ -16,24 +16,27 @@ import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
type AddNewSeriesSearchResultProps = AddSeries;
interface AddNewSeriesSearchResultProps {
series: AddSeries;
}
function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
const {
tvdbId,
titleSlug,
title,
year,
network,
originalLanguage,
genres = [],
status,
statistics = {} as Statistics,
ratings,
overview,
seriesType,
images,
} = series;
function AddNewSeriesSearchResult({
tvdbId,
titleSlug,
title,
year,
network,
originalLanguage,
genres = [],
status,
statistics = {} as Statistics,
ratings,
folder,
overview,
seriesType,
images,
}: AddNewSeriesSearchResultProps) {
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const { isSmallScreen } = useSelector(createDimensionsSelector());
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
@@ -168,13 +171,8 @@ function AddNewSeriesSearchResult({
<AddNewSeriesModal
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
tvdbId={tvdbId}
title={title}
year={year}
overview={overview}
folder={folder}
series={series}
initialSeriesType={seriesType}
images={images}
onModalClose={handleAddSeriesModalClose}
/>
</div>

View File

@@ -0,0 +1,51 @@
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Series from 'Series/Series';
import { updateItem } from 'Store/Actions/baseActions';
type AddSeriesPayload = AddSeries & AddSeriesOptions;
export const useLookupSeries = (query: string) => {
return useApiQuery<AddSeries[]>({
path: '/series/lookup',
queryParams: {
term: query,
},
queryOptions: {
enabled: !!query,
// Disable refetch on window focus to prevent refetching when the user switch tabs
refetchOnWindowFocus: false,
},
});
};
export const useAddSeries = () => {
const dispatch = useDispatch();
const onAddSuccess = useCallback(
(data: Series) => {
dispatch(updateItem({ section: 'series', ...data }));
},
[dispatch]
);
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
{
path: '/series',
method: 'POST',
mutationOptions: {
onSuccess: onAddSuccess,
},
}
);
return {
isAdding: isPending,
addError: error,
addSeries: mutate,
};
};

View File

@@ -0,0 +1,7 @@
import Series from 'Series/Series';
interface AddSeries extends Series {
folder: string;
}
export default AddSeries;

View File

@@ -1,6 +1,10 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import {
setAddSeriesOption,
useAddSeriesOption,
} from 'AddSeries/addSeriesOptionsStore';
import { SelectProvider } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
@@ -8,7 +12,6 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { kinds } from 'Helpers/Props';
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import { clearImportSeries } from 'Store/Actions/importSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import translate from 'Utilities/String/translate';
@@ -48,9 +51,7 @@ function ImportSeries() {
(state: AppState) => state.settings.qualityProfiles.items
);
const defaultQualityProfileId = useSelector(
(state: AppState) => state.addSeries.defaults.qualityProfileId
);
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
const scrollerRef = useRef<HTMLDivElement>(null);
@@ -76,9 +77,7 @@ function ImportSeries() {
!defaultQualityProfileId ||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
) {
dispatch(
setAddSeriesDefault({ qualityProfileId: qualityProfiles[0].id })
);
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
}
}, [defaultQualityProfileId, qualityProfiles, dispatch]);

View File

@@ -1,5 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
AddSeriesOptions,
setAddSeriesOption,
useAddSeriesOptions,
} from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import CheckInput from 'Components/Form/CheckInput';
@@ -12,7 +17,6 @@ import PageContentFooter from 'Components/Page/PageContentFooter';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesMonitor, SeriesType } from 'Series/Series';
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import {
cancelLookupSeries,
importSeries,
@@ -33,7 +37,7 @@ function ImportSeriesFooter() {
qualityProfileId: defaultQualityProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder,
} = useSelector((state: AppState) => state.addSeries.defaults);
} = useAddSeriesOptions();
const { isLookingUpSeries, isImporting, items, importError } = useSelector(
(state: AppState) => state.importSeries
@@ -110,7 +114,7 @@ function ImportSeriesFooter() {
]);
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
if (name === 'monitor') {
setMonitor(value as SeriesMonitor);
} else if (name === 'qualityProfileId') {
@@ -121,7 +125,7 @@ function ImportSeriesFooter() {
setSeasonFolder(value as boolean);
}
dispatch(setAddSeriesDefault({ [name]: value }));
setAddSeriesOption(name as keyof AddSeriesOptions, value);
selectedIds.forEach((id) => {
dispatch(

View File

@@ -75,11 +75,6 @@ function ImportSeriesRow({ id }: ImportSeriesRowProps) {
[selectDispatch]
);
console.info(
'\x1b[36m[MarkTest] is selected\x1b[0m',
selectState.selectedState[id]
);
return (
<>
<VirtualTableSelectCell

View File

@@ -1,6 +1,7 @@
import React, { RefObject, useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
@@ -59,9 +60,8 @@ function ImportSeriesTable({
}: ImportSeriesTableProps) {
const dispatch = useDispatch();
const { monitor, qualityProfileId, seriesType, seasonFolder } = useSelector(
(state: AppState) => state.addSeries.defaults
);
const { monitor, qualityProfileId, seriesType, seasonFolder } =
useAddSeriesOptions();
const items = useSelector((state: AppState) => state.importSeries.items);
const { isSmallScreen } = useSelector(createDimensionsSelector());

View File

@@ -1,5 +1,13 @@
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import {
autoUpdate,
flip,
FloatingPortal,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormInputButton from 'Components/Form/FormInputButton';
@@ -7,7 +15,6 @@ import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Portal from 'Components/Portal';
import { icons, kinds } from 'Helpers/Props';
import {
queueLookupSeries,
@@ -47,9 +54,6 @@ function ImportSeriesSelectSeries({
// @ts-expect-error - ignoring this for now
} = useSelector(createImportSeriesItemSelector(id, { id }));
const buttonId = useId();
const contentId = useId();
const updater = useRef<(() => void) | null>(null);
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();
const [term, setTerm] = useState('');
@@ -57,37 +61,6 @@ function ImportSeriesSelectSeries({
const errorMessage = getErrorMessage(error);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const button = document.getElementById(buttonId);
const content = document.getElementById(contentId);
const eventTarget = event.target as HTMLElement;
if (!button || !eventTarget.isConnected) {
return;
}
if (
!button.contains(eventTarget) &&
content &&
!content.contains(eventTarget) &&
isOpen
) {
setIsOpen(false);
window.removeEventListener('click', handleWindowClick);
}
},
[isOpen, buttonId, contentId, setIsOpen]
);
const addListener = useCallback(() => {
window.addEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const removeListener = useCallback(() => {
window.removeEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const handlePress = useCallback(() => {
setIsOpen((prevIsOpen) => !prevIsOpen);
}, []);
@@ -147,157 +120,139 @@ function ImportSeriesSelectSeries({
[id, items, dispatch, onInputChange]
);
useEffect(() => {
if (updater.current) {
updater.current();
}
});
useEffect(() => {
if (isOpen) {
addListener();
} else {
removeListener();
}
return removeListener;
}, [isOpen, addListener, removeListener]);
useEffect(() => {
setTerm(itemTerm);
}, [itemTerm]);
const { refs, context, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
],
open: isOpen,
placement: 'bottom',
whileElementsMounted: autoUpdate,
onOpenChange: setIsOpen,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
]);
return (
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={buttonId}>
<Link
// ref={ref}
className={styles.button}
component="div"
onPress={handlePress}
>
{isLookingUpSeries && isQueued && !isPopulated ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
<>
<div ref={refs.setReference} {...getReferenceProps()}>
<Link className={styles.button} component="div" onPress={handlePress}>
{isLookingUpSeries && isQueued && !isPopulated ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isPopulated && selectedSeries && isExistingSeries ? (
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
) : null}
{isPopulated && selectedSeries && isExistingSeries ? (
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
) : null}
{isPopulated && selectedSeries ? (
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
network={selectedSeries.network}
isExistingSeries={isExistingSeries}
/>
) : null}
{isPopulated && selectedSeries ? (
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
network={selectedSeries.network}
isExistingSeries={isExistingSeries}
/>
) : null}
{isPopulated && !selectedSeries ? (
<div>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{isPopulated && !selectedSeries ? (
<div>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('NoMatchFound')}
</div>
) : null}
{translate('NoMatchFound')}
</div>
) : null}
{!isFetching && !!error ? (
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{!isFetching && !!error ? (
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('SearchFailedError')}
</div>
) : null}
{translate('SearchFailedError')}
</div>
) : null}
<div className={styles.dropdownArrowContainer}>
<Icon name={icons.CARET_DOWN} />
</div>
</Link>
<div className={styles.dropdownArrowContainer}>
<Icon name={icons.CARET_DOWN} />
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom"
modifiers={{
preventOverflow: {
boundariesElement: 'viewport',
},
}}
>
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={ref}
id={contentId}
className={styles.contentContainer}
style={style}
>
{isOpen ? (
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={term}
onChange={handleSearchInputChange}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={handleRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
</div>
<div className={styles.results}>
{items.map((item) => {
return (
<ImportSeriesSearchResult
key={item.tvdbId}
tvdbId={item.tvdbId}
title={item.title}
year={item.year}
network={item.network}
onPress={handleSeriesSelect}
/>
);
})}
</div>
</Link>
</div>
{isOpen ? (
<FloatingPortal id="portal-root">
<div
ref={refs.setFloating}
className={styles.contentContainer}
style={floatingStyles}
{...getFloatingProps()}
>
{isOpen ? (
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
) : null}
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={term}
onChange={handleSearchInputChange}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={handleRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
</div>
<div className={styles.results}>
{items.map((item) => {
return (
<ImportSeriesSearchResult
key={item.tvdbId}
tvdbId={item.tvdbId}
title={item.title}
year={item.year}
network={item.network}
onPress={handleSeriesSelect}
/>
);
})}
</div>
</div>
);
}}
</Popper>
</Portal>
</Manager>
) : null}
</div>
</FloatingPortal>
) : null}
</>
);
}

View File

@@ -0,0 +1,31 @@
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeriesOptions {
rootFolderPath: string;
monitor: SeriesMonitor;
qualityProfileId: number;
seriesType: SeriesType;
seasonFolder: boolean;
searchForMissingEpisodes: boolean;
searchForCutoffUnmetEpisodes: boolean;
tags: number[];
}
const { useOptions, useOption, setOption } =
createOptionsStore<AddSeriesOptions>('add_series_options', () => {
return {
rootFolderPath: '',
monitor: 'all',
qualityProfileId: 0,
seriesType: 'standard',
seasonFolder: true,
searchForMissingEpisodes: false,
searchForCutoffUnmetEpisodes: false,
tags: [],
};
});
export const useAddSeriesOptions = useOptions;
export const useAddSeriesOption = useOption;
export const setAddSeriesOption = setOption;

View File

@@ -11,6 +11,7 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import { fetchUpdates } from 'Store/Actions/systemActions';
import UpdateChanges from 'System/Updates/UpdateChanges';
import useUpdates from 'System/Updates/useUpdates';
import Update from 'typings/Update';
import translate from 'Utilities/String/translate';
import AppState from './State/AppState';
@@ -65,14 +66,12 @@ interface AppUpdatedModalContentProps {
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const dispatch = useDispatch();
const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { isPopulated, error, items } = useSelector(
(state: AppState) => state.system.updates
);
const { isFetched, error, data } = useUpdates();
const previousVersion = usePrevious(version);
const { onModalClose } = props;
const update = mergeUpdates(items, version, prevVersion);
const update = mergeUpdates(data, version, prevVersion);
const handleSeeChangesPress = useCallback(() => {
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
@@ -100,7 +99,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
/>
</div>
{isPopulated && !error && !!update ? (
{isFetched && !error && !!update ? (
<div>
{update.changes ? (
<div className={styles.maintenance}>
@@ -126,7 +125,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
</div>
) : null}
{!isPopulated && !error ? <LoadingIndicator /> : null}
{!isFetched && !error ? <LoadingIndicator /> : null}
</ModalBody>
<ModalFooter>

View File

@@ -1,20 +1,9 @@
import { useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import useTheme from 'Helpers/Hooks/useTheme';
import themes from 'Styles/Themes';
import AppState from './State/AppState';
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
(theme) => {
return theme;
}
);
}
function ApplyTheme() {
const theme = useSelector(createThemeSelector());
const theme = useTheme();
const updateCSSVariables = useCallback(() => {
Object.entries(themes[theme]).forEach(([key, value]) => {

View File

@@ -1,25 +0,0 @@
import AppSectionState, { Error } from 'App/State/AppSectionState';
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeries extends Series {
folder: string;
}
interface AddSeriesAppState extends AppSectionState<AddSeries> {
isAdding: boolean;
isAdded: boolean;
addError: Error | undefined;
defaults: {
rootFolderPath: string;
monitor: SeriesMonitor;
qualityProfileId: number;
seriesType: SeriesType;
seasonFolder: boolean;
tags: number[];
searchForMissingEpisodes: boolean;
searchForCutoffUnmetEpisodes: boolean;
};
}
export default AddSeriesAppState;

View File

@@ -1,7 +1,6 @@
import ModelBase from 'App/ModelBase';
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
import AddSeriesAppState from './AddSeriesAppState';
import { Error } from './AppSectionState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
@@ -19,7 +18,6 @@ import OrganizePreviewAppState from './OrganizePreviewAppState';
import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
@@ -50,7 +48,6 @@ export interface PropertyFilter {
export interface Filter {
key: string;
label: string | (() => string);
type: string;
filters: PropertyFilter[];
}
@@ -83,7 +80,6 @@ export interface AppSectionState {
}
interface AppState {
addSeries: AddSeriesAppState;
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;
@@ -102,7 +98,6 @@ interface AppState {
parse: ParseAppState;
paths: PathsAppState;
providerOptions: ProviderOptionsAppState;
queue: QueueAppState;
releases: ReleasesAppState;
rootFolders: RootFolderAppState;
series: SeriesAppState;

View File

@@ -1,14 +0,0 @@
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from 'App/State/AppSectionState';
import LogEvent from 'typings/LogEvent';
interface LogsAppState
extends AppSectionState<LogEvent>,
AppSectionFilterState<LogEvent>,
PagedAppSectionState,
TableAppSectionState {}
export default LogsAppState;

View File

@@ -1,44 +0,0 @@
import Queue from 'typings/Queue';
import AppSectionState, {
AppSectionFilterState,
AppSectionItemState,
Error,
PagedAppSectionState,
TableAppSectionState,
} from './AppSectionState';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown;
}
export interface QueuePagedAppState
extends AppSectionState<Queue>,
AppSectionFilterState<Queue>,
PagedAppSectionState,
TableAppSectionState {
isGrabbing: boolean;
grabError: Error;
isRemoving: boolean;
removeError: Error;
}
interface QueueAppState {
status: AppSectionItemState<QueueStatus>;
details: QueueDetailsAppState;
paged: QueuePagedAppState;
options: {
includeUnknownSeriesItems: boolean;
};
}
export default QueueAppState;

View File

@@ -3,28 +3,23 @@ import Health from 'typings/Health';
import LogFile from 'typings/LogFile';
import SystemStatus from 'typings/SystemStatus';
import Task from 'typings/Task';
import Update from 'typings/Update';
import AppSectionState, { AppSectionItemState } from './AppSectionState';
import BackupAppState from './BackupAppState';
import LogsAppState from './LogsAppState';
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type TaskAppState = AppSectionState<Task>;
export type LogFilesAppState = AppSectionState<LogFile>;
export type UpdateAppState = AppSectionState<Update>;
interface SystemAppState {
backups: BackupAppState;
diskSpace: DiskSpaceAppState;
health: HealthAppState;
logFiles: LogFilesAppState;
logs: LogsAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
updateLogFiles: LogFilesAppState;
updates: UpdateAppState;
}
export default SystemAppState;

View File

@@ -17,6 +17,7 @@ export interface TagDetail extends ModelBase {
indexerIds: number[];
notificationIds: number[];
restrictionIds: number[];
excludedReleaseProfileIds: number[];
seriesIds: number[];
}

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
import getStatusStyle from 'Calendar/getStatusStyle';
@@ -13,7 +14,6 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
@@ -57,7 +57,7 @@ function AgendaEvent(props: AgendaEventProps) {
const series = useSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id));
const queueItem = useQueueItemForEpisode(id);
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);

View File

@@ -17,10 +17,6 @@ import {
clearEpisodeFiles,
fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
@@ -74,7 +70,6 @@ function Calendar() {
return () => {
dispatch(clearCalendar());
dispatch(clearQueueDetails());
dispatch(clearEpisodeFiles());
clearTimeout(updateTimeout.current);
};
@@ -90,7 +85,6 @@ function Calendar() {
useEffect(() => {
const repopulate = () => {
dispatch(fetchQueueDetails({ time, view }));
dispatch(fetchCalendar({ time, view }));
};
@@ -125,16 +119,11 @@ function Calendar() {
useEffect(() => {
if (!previousItems || hasDifferentItems(items, previousItems)) {
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
const episodeFileIds = selectUniqueIds<Episode, number>(
items,
'episodeFileId'
);
if (items.length) {
dispatch(fetchQueueDetails({ episodeIds }));
}
if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds }));
}
@@ -144,18 +133,15 @@ function Calendar() {
return (
<div className={styles.calendar}>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
) : null}
{!error && isPopulated && view === 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />
<Agenda />
</div>
) : null}
{!error && isPopulated && view !== 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />

View File

@@ -0,0 +1,78 @@
import moment from 'moment';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import Queue from 'typings/Queue';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate';
function createIsSearchingSelector() {
return createSelector(
(state: AppState) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(
commands.find((command) => {
return command.id === searchMissingCommandId;
})
);
}
);
}
function createMissingEpisodeIdsSelector(queueDetails: Queue[]) {
return createSelector(
(state: AppState) => state.calendar.start,
(state: AppState) => state.calendar.end,
(state: AppState) => state.calendar.items,
(start, end, episodes) => {
return episodes.reduce<number[]>((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some(
(details) => !!details.episode && details.episode.id === episode.id
)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
export default function CalendarMissingEpisodeSearchButton() {
const queueDetails = useQueueDetails();
const missingEpisodeIds = useSelector(
createMissingEpisodeIdsSelector(queueDetails)
);
const isSearchingForMissing = useSelector(createIsSearchingSelector());
const handlePress = useCallback(() => {}, []);
return (
<PageToolbarButton
label={translate('SearchForMissing')}
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={handlePress}
/>
);
}

View File

@@ -1,7 +1,6 @@
import moment from 'moment';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import FilterMenu from 'Components/Menu/FilterMenu';
@@ -11,24 +10,23 @@ 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 Episode from 'Episode/Episode';
import useMeasure from 'Helpers/Hooks/useMeasure';
import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries';
import {
searchMissing,
setCalendarDaysCount,
setCalendarFilter,
} from 'Store/Actions/calendarActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import translate from 'Utilities/String/translate';
import Calendar from './Calendar';
import CalendarFilterModal from './CalendarFilterModal';
import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton';
import CalendarLinkModal from './iCal/CalendarLinkModal';
import Legend from './Legend/Legend';
import CalendarOptionsModal from './Options/CalendarOptionsModal';
@@ -36,60 +34,12 @@ import styles from './CalendarPage.css';
const MINIMUM_DAY_WIDTH = 120;
function createMissingEpisodeIdsSelector() {
return createSelector(
(state: AppState) => state.calendar.start,
(state: AppState) => state.calendar.end,
(state: AppState) => state.calendar.items,
(state: AppState) => state.queue.details.items,
(start, end, episodes, queueDetails) => {
return episodes.reduce<number[]>((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some(
(details) => !!details.episode && details.episode.id === episode.id
)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
function createIsSearchingSelector() {
return createSelector(
(state: AppState) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(
commands.find((command) => {
return command.id === searchMissingCommandId;
})
);
}
);
}
function CalendarPage() {
const dispatch = useDispatch();
const { selectedFilterKey, filters } = useSelector(
const { selectedFilterKey, filters, items } = useSelector(
(state: AppState) => state.calendar
);
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
const isSearchingForMissing = useSelector(createIsSearchingSelector());
const isRssSyncExecuting = useSelector(
createCommandExecutingSelector(commandNames.RSS_SYNC)
);
@@ -127,10 +77,6 @@ function CalendarPage() {
);
}, [dispatch]);
const handleSearchMissingPress = useCallback(() => {
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
}, [missingEpisodeIds, dispatch]);
const handleFilterSelect = useCallback(
(key: string | number) => {
dispatch(setCalendarFilter({ selectedFilterKey: key }));
@@ -138,6 +84,10 @@ function CalendarPage() {
[dispatch]
);
const episodeIds = useMemo(() => {
return selectUniqueIds<Episode, number>(items, 'id');
}, [items]);
useEffect(() => {
if (width === 0) {
return;
@@ -152,71 +102,67 @@ function CalendarPage() {
}, [width, dispatch]);
return (
<PageContent title={translate('Calendar')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('ICalLink')}
iconName={icons.CALENDAR}
onPress={handleGetCalendarLinkPress}
/>
<QueueDetails episodeIds={episodeIds}>
<PageContent title={translate('Calendar')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('ICalLink')}
iconName={icons.CALENDAR}
onPress={handleGetCalendarLinkPress}
/>
<PageToolbarSeparator />
<PageToolbarSeparator />
<PageToolbarButton
label={translate('RssSync')}
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
onPress={handleRssSyncPress}
/>
<PageToolbarButton
label={translate('RssSync')}
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
onPress={handleRssSyncPress}
/>
<PageToolbarButton
label={translate('SearchForMissing')}
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={handleSearchMissingPress}
/>
</PageToolbarSection>
<CalendarMissingEpisodeSearchButton />
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={translate('Options')}
iconName={icons.POSTER}
onPress={handleOptionsPress}
/>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={translate('Options')}
iconName={icons.POSTER}
onPress={handleOptionsPress}
/>
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={!hasSeries}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={handleFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={!hasSeries}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={handleFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody
ref={pageContentRef}
className={styles.calendarPageBody}
innerClassName={styles.calendarInnerPageBody}
>
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
{hasSeries && <Legend />}
</PageContentBody>
<PageContentBody
ref={pageContentRef}
className={styles.calendarPageBody}
innerClassName={styles.calendarInnerPageBody}
>
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
{hasSeries && <Legend />}
</PageContentBody>
<CalendarLinkModal
isOpen={isCalendarLinkModalOpen}
onModalClose={handleGetCalendarLinkModalClose}
/>
<CalendarLinkModal
isOpen={isCalendarLinkModalOpen}
onModalClose={handleGetCalendarLinkModalClose}
/>
<CalendarOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={handleOptionsModalClose}
/>
</PageContent>
<CalendarOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={handleOptionsModalClose}
/>
</PageContent>
</QueueDetails>
);
}

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
@@ -12,7 +13,6 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
@@ -58,7 +58,7 @@ function CalendarEvent(props: CalendarEventProps) {
const series = useSeries(seriesId);
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id));
const queueItem = useQueueItemForEpisode(id);
const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()

View File

@@ -2,7 +2,7 @@ import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
@@ -18,17 +18,6 @@ import translate from 'Utilities/String/translate';
import CalendarEvent from './CalendarEvent';
import styles from './CalendarEventGroup.css';
function createIsDownloadingSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.queue.details,
(details) => {
return details.items.some((item) => {
return !!(item.episodeId && episodeIds.includes(item.episodeId));
});
}
);
}
interface CalendarEventGroupProps {
episodeIds: number[];
seriesId: number;
@@ -42,7 +31,7 @@ function CalendarEventGroup({
events,
onEventModalOpenToggle,
}: CalendarEventGroupProps) {
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds));
const isDownloading = useIsDownloadingEpisodes(episodeIds);
const series = useSeries(seriesId)!;
const { timeFormat, enableColorImpairedMode } = useSelector(
@@ -61,10 +50,10 @@ function CalendarEventGroup({
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
const seasonNumber = firstEpisode.seasonNumber;
const { allDownloaded, anyQueued, anyMonitored, allAbsoluteEpisodeNumbers } =
const { allDownloaded, anyGrabbed, anyMonitored, allAbsoluteEpisodeNumbers } =
useMemo(() => {
let files = 0;
let queued = 0;
let grabbed = 0;
let monitored = 0;
let absoluteEpisodeNumbers = 0;
@@ -73,8 +62,8 @@ function CalendarEventGroup({
files++;
}
if (event.queued) {
queued++;
if (event.grabbed) {
grabbed++;
}
if (series.monitored && event.monitored) {
@@ -88,13 +77,13 @@ function CalendarEventGroup({
return {
allDownloaded: files === events.length,
anyQueued: queued > 0,
anyGrabbed: grabbed > 0,
anyMonitored: monitored > 0,
allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length,
};
}, [series, events]);
const anyDownloading = isDownloading || anyQueued;
const anyDownloading = isDownloading || anyGrabbed;
const statusStyle = getStatusStyle(
allDownloaded,

View File

@@ -10,7 +10,7 @@ import {
interface CalendarEventQueueDetailsProps {
title: string;
size: number;
sizeleft: number;
sizeLeft: number;
estimatedCompletionTime?: string;
status: string;
trackedDownloadState: QueueTrackedDownloadState;
@@ -22,7 +22,7 @@ interface CalendarEventQueueDetailsProps {
function CalendarEventQueueDetails({
title,
size,
sizeleft,
sizeLeft,
estimatedCompletionTime,
status,
trackedDownloadState,
@@ -30,13 +30,13 @@ function CalendarEventQueueDetails({
statusMessages,
errorMessage,
}: CalendarEventQueueDetailsProps) {
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
return (
<QueueDetails
title={title}
size={size}
sizeleft={sizeleft}
sizeLeft={sizeLeft}
estimatedCompletionTime={estimatedCompletionTime}
status={status}
trackedDownloadState={trackedDownloadState}

View File

@@ -22,7 +22,12 @@ interface CalendarLinkModalContentProps {
function CalendarLinkModalContent({
onModalClose,
}: CalendarLinkModalContentProps) {
const [state, setState] = useState({
const [state, setState] = useState<{
unmonitored: boolean;
premieresOnly: boolean;
asAllDay: boolean;
tags: number[];
}>({
unmonitored: false,
premieresOnly: false,
asAllDay: false,

View File

@@ -1,3 +1,4 @@
import { autoUpdate, flip, size, useFloating } from '@floating-ui/react-dom';
import classNames from 'classnames';
import React, {
FocusEvent,
@@ -19,8 +20,6 @@ import Autosuggest, {
RenderInputComponentProps,
RenderSuggestionsContainerParams,
} from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { InputChanged } from 'typings/inputs';
import styles from './AutoSuggestInput.css';
@@ -37,7 +36,6 @@ interface AutoSuggestInputProps<T>
hasError?: boolean;
hasWarning?: boolean;
enforceMaxHeight?: boolean;
minHeight?: number;
maxHeight?: number;
renderInputComponent?: (
inputProps: RenderInputComponentProps,
@@ -70,7 +68,6 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
enforceMaxHeight = true,
hasError,
hasWarning,
minHeight = 50,
maxHeight = 200,
getSuggestionValue,
renderSuggestion,
@@ -89,95 +86,60 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
const updater = useRef<(() => void) | null>(null);
const previousSuggestions = usePrevious(suggestions);
const handleComputeMaxHeight = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any) => {
const { top, bottom, width } = data.offsets.reference;
if (enforceMaxHeight) {
data.styles.maxHeight = maxHeight;
} else {
const windowHeight = window.innerHeight;
if (/^botton/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
}
data.styles.width = width;
return data;
},
[enforceMaxHeight, maxHeight]
);
const { refs, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
size({
apply({ availableHeight, elements, rects }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
maxHeight: `${Math.max(0, availableHeight)}px`,
});
},
}),
],
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
});
const createRenderInputComponent = useCallback(
(inputProps: RenderInputComponentProps) => {
return (
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
if (renderInputComponent) {
return renderInputComponent(inputProps, refs.setReference);
}
return (
<div ref={ref}>
<input {...inputProps} />
</div>
);
}}
</Reference>
return (
<div ref={refs.setReference}>
<input {...inputProps} />
</div>
);
},
[renderInputComponent]
[refs.setReference, renderInputComponent]
);
const renderSuggestionsContainer = useCallback(
({ containerProps, children }: RenderSuggestionsContainerParams) => {
return (
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
flip: {
padding: minHeight,
},
<div
ref={refs.setFloating}
style={floatingStyles}
className={children ? styles.suggestionsContainerOpen : undefined}
>
<div
{...containerProps}
style={{
maxHeight: enforceMaxHeight ? maxHeight : undefined,
}}
>
{({ ref: popperRef, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={
children ? styles.suggestionsContainerOpen : undefined
}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight,
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
{children}
</div>
</div>
);
},
[minHeight, handleComputeMaxHeight]
[enforceMaxHeight, floatingStyles, maxHeight, refs.setFloating]
);
const handleInputKeyDown = useCallback(
@@ -236,23 +198,21 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
}, [suggestions, previousSuggestions]);
return (
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={createRenderInputComponent}
renderSuggestionsContainer={renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={createRenderInputComponent}
renderSuggestionsContainer={renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
);
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import NumberInput, { NumberInputChanged } from './NumberInput';
export interface FloatInputProps {
name: string;
value?: number | null;
min?: number;
max?: number;
step?: number;
placeholder?: string;
className?: string;
onChange: (change: NumberInputChanged) => void;
}
function FloatInput(props: FloatInputProps) {
return <NumberInput {...props} isFloat={true} />;
}
export default FloatInput;

View File

@@ -7,6 +7,7 @@ import translate from 'Utilities/String/translate';
import AutoCompleteInput, { AutoCompleteInputProps } from './AutoCompleteInput';
import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
import CheckInput, { CheckInputProps } from './CheckInput';
import FloatInput, { FloatInputProps } from './FloatInput';
import { FormInputButtonProps } from './FormInputButton';
import FormInputHelpText from './FormInputHelpText';
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
@@ -65,7 +66,7 @@ const componentMap: Record<InputType, ElementType> = {
downloadClientSelect: DownloadClientSelectInput,
dynamicSelect: ProviderDataSelectInput,
file: TextInput,
float: NumberInput,
float: FloatInput,
indexerFlagsSelect: IndexerFlagsSelectInput,
indexerSelect: IndexerSelectInput,
keyValueList: KeyValueListInput,
@@ -110,7 +111,7 @@ type PickProps<V, C extends InputType> = C extends 'text'
: C extends 'file'
? TextInputProps
: C extends 'float'
? TextInputProps
? FloatInputProps
: C extends 'indexerFlagsSelect'
? IndexerFlagsSelectInputProps
: C extends 'indexerSelect'
@@ -139,11 +140,11 @@ type PickProps<V, C extends InputType> = C extends 'text'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
EnhancedSelectInputProps<any, V>
: C extends 'seriesTag'
? SeriesTagInputProps
? SeriesTagInputProps<V>
: C extends 'seriesTypeSelect'
? SeriesTypeSelectInputProps
: C extends 'tag'
? SeriesTagInputProps
? SeriesTagInputProps<V>
: C extends 'tagSelect'
? TagSelectInputProps
: C extends 'text'
@@ -222,7 +223,7 @@ function FormInputGroup<T, C extends InputType>(
<div className={containerClassName}>
<div className={className}>
<div className={styles.inputContainer}>
{/* @ts-expect-error - tpyes are validated already */}
{/* @ts-expect-error - types are validated already */}
<InputComponent
className={inputClassName}
helpText={helpText}

View File

@@ -24,13 +24,17 @@ function parseValue(
return newValue;
}
export interface NumberInputChanged extends InputChanged<number | null> {
isFloat?: boolean;
}
export interface NumberInputProps
extends Omit<TextInputProps, 'value' | 'onChange'> {
value?: number | null;
min?: number;
max?: number;
isFloat?: boolean;
onChange: (input: InputChanged<number | null>) => void;
onChange: (change: NumberInputChanged) => void;
}
function NumberInput({
@@ -50,11 +54,14 @@ function NumberInput({
const handleChange = useCallback(
({ name, value: newValue }: InputChanged<string>) => {
setValue(newValue);
const parsedValue = parseValue(newValue, isFloat, min, max);
setValue(parsedValue == null ? '' : parsedValue.toString());
onChange({
name,
value: parseValue(newValue, isFloat, min, max),
value: parsedValue,
isFloat,
});
},
[isFloat, min, max, onChange, setValue]
@@ -75,6 +82,7 @@ function NumberInput({
onChange({
name,
value: parsedValue,
isFloat,
});
isFocused.current = false;

View File

@@ -120,7 +120,7 @@ function ProviderFieldFormGroup<T>({
helpTextWarning={helpTextWarning}
helpLink={helpLink}
placeholder={placeholder}
// @ts-expect-error - this isn;'t available on all types
// @ts-expect-error - this isn't available on all types
selectOptionsProviderAction={selectOptionsProviderAction}
value={value}
values={selectValues}

View File

@@ -42,15 +42,10 @@
color: var(--disabledInputColor);
}
.optionsContainer {
z-index: $popperZIndex;
max-height: vh(50);
width: auto;
}
.options {
composes: scroller from '~Components/Scroller/Scroller.css';
z-index: $popperZIndex;
border: 1px solid var(--inputBorderColor);
border-radius: 4px;
background-color: var(--inputBackgroundColor);

View File

@@ -13,7 +13,6 @@ interface CssExports {
'mobileCloseButton': string;
'mobileCloseButtonContainer': string;
'options': string;
'optionsContainer': string;
'optionsInnerModalBody': string;
'optionsModal': string;
'optionsModalBody': string;

View File

@@ -1,3 +1,13 @@
import {
autoUpdate,
flip,
FloatingPortal,
size,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import classNames from 'classnames';
import React, {
ElementType,
@@ -6,31 +16,24 @@ import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import Portal from 'Components/Portal';
import Scroller from 'Components/Scroller/Scroller';
import useMeasure from 'Helpers/Hooks/useMeasure';
import { icons } from 'Helpers/Props';
import ArrayElement from 'typings/Helpers/ArrayElement';
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import getUniqueElementId from 'Utilities/getUniqueElementId';
import TextInput from '../TextInput';
import HintedSelectInputOption from './HintedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 30;
function isArrowKey(keyCode: number) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@@ -162,10 +165,6 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
onOpen,
} = props;
const [measureRef, { width }] = useMeasure();
const updater = useRef<(() => void) | null>(null);
const buttonId = useMemo(() => getUniqueElementId(), []);
const optionsId = useMemo(() => getUniqueElementId(), []);
const [selectedIndex, setSelectedIndex] = useState(
getSelectedIndex(value, values)
);
@@ -175,6 +174,38 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
const isMultiSelect = Array.isArray(value);
const selectedOption = getSelectedOption(selectedIndex, values);
const { refs, context, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
size({
apply({ availableHeight, elements, rects }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
maxHeight: `${Math.max(
0,
Math.min(window.innerHeight / 2, availableHeight)
)}px`,
});
},
}),
],
open: isOpen,
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
onOpenChange: setIsOpen,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
]);
const selectedValue = useMemo(() => {
if (values.length) {
return value;
@@ -189,59 +220,9 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
return '';
}, [value, values, isMultiSelect]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleComputeMaxHeight = useCallback((data: any) => {
const { top, bottom } = data.offsets.reference;
const windowHeight = window.innerHeight;
if (/^bottom/.test(data.placement)) {
data.styles.maxHeight =
windowHeight - bottom - MINIMUM_DISTANCE_FROM_EDGE;
} else {
data.styles.maxHeight = top - MINIMUM_DISTANCE_FROM_EDGE;
}
return data;
}, []);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const button = document.getElementById(buttonId);
const options = document.getElementById(optionsId);
const eventTarget = event.target as HTMLElement;
if (!button || !eventTarget.isConnected || isMobile) {
return;
}
if (
!button.contains(eventTarget) &&
options &&
!options.contains(eventTarget) &&
isOpen
) {
setIsOpen(false);
window.removeEventListener('click', handleWindowClick);
}
},
[isMobile, isOpen, buttonId, optionsId, setIsOpen]
);
const addListener = useCallback(() => {
window.addEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const removeListener = useCallback(() => {
window.removeEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const handlePress = useCallback(() => {
if (!isOpen && onOpen) {
onOpen();
}
setIsOpen(!isOpen);
}, [isOpen, setIsOpen, onOpen]);
setIsOpen((prevIsOpen) => !prevIsOpen);
}, []);
const handleSelect = useCallback(
(newValue: ArrayElement<V>) => {
@@ -298,10 +279,9 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
const handleFocus = useCallback(() => {
if (isOpen) {
removeListener();
setIsOpen(false);
}
}, [isOpen, setIsOpen, removeListener]);
}, [isOpen, setIsOpen]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
@@ -395,172 +375,122 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
[onChange]
);
useEffect(() => {
if (updater.current) {
updater.current();
}
});
useEffect(() => {
if (isOpen) {
addListener();
} else {
removeListener();
onOpen?.();
}
return removeListener;
}, [isOpen, addListener, removeListener]);
}, [isOpen, onOpen]);
return (
<div>
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={buttonId}>
<div ref={measureRef}>
{isEditable && typeof value === 'string' ? (
<div className={styles.editableContainer}>
<TextInput
className={className}
name={name}
value={value}
readOnly={isDisabled}
hasError={hasError}
hasWarning={hasWarning}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleEditChange}
/>
<Link
className={classNames(
styles.dropdownArrowContainerEditable,
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
)}
onPress={handlePress}
>
{isFetching ? (
<LoadingIndicator
className={styles.loading}
size={20}
/>
) : null}
<>
<div ref={refs.setReference} {...getReferenceProps()}>
{isEditable && typeof value === 'string' ? (
<div className={styles.editableContainer}>
<TextInput
className={className}
name={name}
value={value}
readOnly={isDisabled}
hasError={hasError}
hasWarning={hasWarning}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleEditChange}
/>
<Link
className={classNames(
styles.dropdownArrowContainerEditable,
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
)}
onPress={handlePress}
>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</Link>
</div>
) : (
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPress={handlePress}
>
<SelectedValueComponent
values={values}
{...selectedValueOptions}
selectedValue={selectedValue}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : selectedValue}
</SelectedValueComponent>
<div
className={
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
}
>
{isFetching ? (
<LoadingIndicator
className={styles.loading}
size={20}
/>
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</div>
</Link>
)}
</div>
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
}}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</Link>
</div>
) : (
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPress={handlePress}
>
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
<SelectedValueComponent
values={values}
{...selectedValueOptions}
selectedValue={selectedValue}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : selectedValue}
</SelectedValueComponent>
<div
className={
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
}
>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</div>
</Link>
)}
</div>
{!isMobile && isOpen ? (
<FloatingPortal id="portal-root">
<Scroller
ref={refs.setFloating}
className={styles.options}
style={floatingStyles}
{...getFloatingProps()}
>
{values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected =
v.parentKey !== undefined &&
Array.isArray(value) &&
value.includes(v.parentKey);
const { key, ...other } = v;
return (
<div
ref={ref}
id={optionsId}
className={styles.optionsContainer}
style={{
...style,
minWidth: width,
}}
<OptionComponent
key={v.key}
id={v.key}
depth={depth}
isSelected={isSelectedItem(index, value, values)}
isDisabled={parentSelected}
isMultiSelect={isMultiSelect}
{...valueOptions}
{...other}
isMobile={false}
onSelect={handleSelect}
>
{isOpen && !isMobile ? (
<Scroller
className={styles.options}
style={{
maxHeight: style.maxHeight,
}}
>
{values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected =
v.parentKey !== undefined &&
Array.isArray(value) &&
value.includes(v.parentKey);
const { key, ...other } = v;
return (
<OptionComponent
key={v.key}
id={v.key}
depth={depth}
isSelected={isSelectedItem(index, value, values)}
isDisabled={parentSelected}
isMultiSelect={isMultiSelect}
{...valueOptions}
{...other}
isMobile={false}
onSelect={handleSelect}
>
{v.value}
</OptionComponent>
);
})}
</Scroller>
) : null}
</div>
{v.value}
</OptionComponent>
);
}}
</Popper>
</Portal>
</Manager>
})}
</Scroller>
</FloatingPortal>
) : null}
{isMobile ? (
<Modal
@@ -615,7 +545,7 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
</ModalBody>
</Modal>
) : null}
</div>
</>
);
}

View File

@@ -19,7 +19,6 @@ function HintedSelectInputOption(props: HintedSelectInputOptionProps) {
hint,
depth,
isSelected = false,
isMultiSelect,
isMobile,
...otherProps
} = props;

View File

@@ -88,13 +88,10 @@ function QualityProfileSelectInput({
);
const handleChange = useCallback(
({ value: newValue }: EnhancedSelectInputChanged<string | number>) => {
onChange({
name,
value: newValue === 'noChange' ? value : newValue,
});
({ value }: EnhancedSelectInputChanged<string | number>) => {
onChange({ name, value });
},
[name, value, onChange]
[name, onChange]
);
useEffect(() => {

View File

@@ -21,6 +21,7 @@ const ADD_NEW_KEY = 'addNew';
export interface RootFolderSelectInputValue
extends EnhancedSelectInputValue<string> {
freeSpace?: number;
isMissing?: boolean;
}
@@ -42,66 +43,58 @@ function createRootFolderOptionsSelector(
includeNoChange: boolean,
includeNoChangeDisabled: boolean
) {
return createSelector(
createRootFoldersSelector(),
(rootFolders) => {
const values: RootFolderSelectInputValue[] = rootFolders.items.map(
(rootFolder) => {
return {
key: rootFolder.path,
value: rootFolder.path,
freeSpace: rootFolder.freeSpace,
isMissing: false,
};
}
);
if (includeNoChange) {
values.unshift({
key: 'noChange',
get value() {
return translate('NoChange');
},
isDisabled: includeNoChangeDisabled,
return createSelector(createRootFoldersSelector(), (rootFolders) => {
const values: RootFolderSelectInputValue[] = rootFolders.items.map(
(rootFolder) => {
return {
key: rootFolder.path,
value: rootFolder.path,
freeSpace: rootFolder.freeSpace,
isMissing: false,
});
};
}
);
if (!values.length) {
values.push({
key: '',
value: '',
isDisabled: true,
isHidden: true,
});
}
if (
includeMissingValue &&
value &&
!values.find((v) => v.key === value)
) {
values.push({
key: value,
value,
isMissing: true,
isDisabled: true,
});
}
values.push({
key: ADD_NEW_KEY,
value: translate('AddANewPath'),
if (includeNoChange) {
values.unshift({
key: 'noChange',
get value() {
return translate('NoChange');
},
isDisabled: includeNoChangeDisabled,
isMissing: false,
});
return {
values,
isSaving: rootFolders.isSaving,
saveError: rootFolders.saveError,
};
}
);
if (!values.length) {
values.push({
key: '',
value: '',
isDisabled: true,
isHidden: true,
});
}
if (includeMissingValue && value && !values.find((v) => v.key === value)) {
values.push({
key: value,
value,
isMissing: true,
isDisabled: true,
});
}
values.push({
key: ADD_NEW_KEY,
value: translate('AddANewPath'),
});
return {
values,
isSaving: rootFolders.isSaving,
saveError: rootFolders.saveError,
};
});
}
function RootFolderSelectInput({

View File

@@ -18,18 +18,16 @@ interface RootFolderSelectInputOptionProps
isWindows?: boolean;
}
function RootFolderSelectInputOption(props: RootFolderSelectInputOptionProps) {
const {
id,
value,
freeSpace,
isMissing,
seriesFolder,
isMobile,
isWindows,
...otherProps
} = props;
function RootFolderSelectInputOption({
id,
value,
freeSpace,
isMissing,
seriesFolder,
isMobile,
isWindows,
...otherProps
}: RootFolderSelectInputOptionProps) {
const slashCharacter = isWindows ? '\\' : '/';
return (

View File

@@ -30,3 +30,11 @@
text-align: right;
font-size: $smallFontSize;
}
.isMissing {
flex: 0 0 auto;
margin-left: 15px;
color: var(--dangerColor);
text-align: right;
font-size: $smallFontSize;
}

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'freeSpace': string;
'isMissing': string;
'path': string;
'pathContainer': string;
'selectedValue': string;

View File

@@ -8,27 +8,23 @@ import styles from './RootFolderSelectInputSelectedValue.css';
interface RootFolderSelectInputSelectedValueProps {
selectedValue: string;
values: RootFolderSelectInputValue[];
freeSpace?: number;
seriesFolder?: string;
isWindows?: boolean;
includeFreeSpace?: boolean;
}
function RootFolderSelectInputSelectedValue(
props: RootFolderSelectInputSelectedValueProps
) {
const {
selectedValue,
values,
freeSpace,
seriesFolder,
includeFreeSpace = true,
isWindows,
...otherProps
} = props;
function RootFolderSelectInputSelectedValue({
selectedValue,
values,
seriesFolder,
includeFreeSpace = true,
isWindows,
...otherProps
}: RootFolderSelectInputSelectedValueProps) {
const slashCharacter = isWindows ? '\\' : '/';
const value = values.find((v) => v.key === selectedValue)?.value;
const { value, freeSpace, isMissing } =
values.find((v) => v.key === selectedValue) ||
({} as RootFolderSelectInputValue);
return (
<EnhancedSelectInputSelectedValue
@@ -53,6 +49,10 @@ function RootFolderSelectInputSelectedValue(
})}
</div>
) : null}
{isMissing ? (
<div className={styles.isMissing}>{translate('Missing')}</div>
) : null}
</EnhancedSelectInputSelectedValue>
);
}

View File

@@ -2,10 +2,12 @@
import React, { SyntheticEvent } from 'react';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
import EnhancedSelectInput, {
EnhancedSelectInputValue,
} from './EnhancedSelectInput';
import styles from './UMaskInput.css';
const umaskOptions = [
const umaskOptions: EnhancedSelectInputValue<string>[] = [
{
key: '755',
get value() {

View File

@@ -1,9 +1,15 @@
import classNames from 'classnames';
import React, { ChangeEvent, SyntheticEvent, useCallback } from 'react';
import React, {
ChangeEvent,
ComponentProps,
SyntheticEvent,
useCallback,
} from 'react';
import { InputChanged } from 'typings/inputs';
import styles from './SelectInput.css';
interface SelectInputOption {
export interface SelectInputOption
extends Pick<ComponentProps<'option'>, 'disabled'> {
key: string | number;
value: string | number | (() => string | number);
}

View File

@@ -1,21 +1,25 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { addTag } from 'Store/Actions/tagActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { InputChanged } from 'typings/inputs';
import sortByProp from 'Utilities/Array/sortByProp';
import TagInput, { TagBase } from './TagInput';
import TagInput, { TagBase, TagInputProps } from './TagInput';
interface SeriesTag extends TagBase {
id: number;
name: string;
}
export interface SeriesTagInputProps {
export interface SeriesTagInputProps<V>
extends Omit<
TagInputProps<SeriesTag>,
'tags' | 'tagList' | 'onTagAdd' | 'onTagDelete' | 'onChange'
> {
name: string;
value: number[];
onChange: (change: InputChanged<number[]>) => void;
value: V;
onChange: (change: InputChanged<V>) => void;
}
const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i');
@@ -59,28 +63,49 @@ function createSeriesTagsSelector(tags: number[]) {
});
}
export default function SeriesTagInput({
export default function SeriesTagInput<V extends number | number[]>({
name,
value,
onChange,
}: SeriesTagInputProps) {
...otherProps
}: SeriesTagInputProps<V>) {
const dispatch = useDispatch();
const isArray = Array.isArray(value);
const arrayValue = useMemo(() => {
if (isArray) {
return value as number[];
}
return value === 0 ? [] : [value as number];
}, [isArray, value]);
const { tags, tagList, allTags } = useSelector(
createSeriesTagsSelector(value)
createSeriesTagsSelector(arrayValue)
);
const handleTagCreated = useCallback(
(tag: SeriesTag) => {
onChange({ name, value: [...value, tag.id] });
if (isArray) {
onChange({ name, value: [...value, tag.id] as V });
} else {
onChange({
name,
value: tag.id as V,
});
}
},
[name, value, onChange]
[name, value, isArray, onChange]
);
const handleTagAdd = useCallback(
(newTag: SeriesTag) => {
if (newTag.id) {
onChange({ name, value: [...value, newTag.id] });
if (isArray) {
onChange({ name, value: [...value, newTag.id] as V });
} else {
onChange({ name, value: newTag.id as V });
}
return;
}
@@ -96,21 +121,26 @@ export default function SeriesTagInput({
);
}
},
[name, value, allTags, handleTagCreated, onChange, dispatch]
[name, value, isArray, allTags, handleTagCreated, onChange, dispatch]
);
const handleTagDelete = useCallback(
({ index }: { index: number }) => {
const newValue = value.slice();
newValue.splice(index, 1);
if (isArray) {
const newValue = value.slice();
newValue.splice(index, 1);
onChange({ name, value: newValue });
onChange({ name, value: newValue as V });
} else {
onChange({ name, value: 0 as V });
}
},
[name, value, onChange]
[name, value, isArray, onChange]
);
return (
<TagInput
{...otherProps}
name={name}
tags={tags}
tagList={tagList}

View File

@@ -14,7 +14,7 @@ import {
RenderSuggestion,
SuggestionsFetchRequestedParams,
} from 'react-autosuggest';
import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
import { useDebouncedCallback } from 'use-debounce';
import { Kind } from 'Helpers/Props/kinds';
import { InputChanged } from 'typings/inputs';
import AutoSuggestInput from '../AutoSuggestInput';

View File

@@ -26,6 +26,10 @@
color: var(--warningColor);
}
.primary {
color: var(--primaryColor);
}
.purple {
color: var(--purple);
}

View File

@@ -6,6 +6,7 @@ interface CssExports {
'disabled': string;
'info': string;
'pink': string;
'primary': string;
'purple': string;
'success': string;
'warning': string;

View File

@@ -1,41 +1,22 @@
import {
autoUpdate,
flip,
FloatingPortal,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import React, {
ReactElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { Manager, Popper, PopperProps, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import styles from './Menu.css';
const sharedPopperOptions = {
modifiers: {
preventOverflow: {
padding: 0,
},
flip: {
padding: 0,
},
},
};
const popperOptions: {
right: Partial<PopperProps>;
left: Partial<PopperProps>;
} = {
right: {
...sharedPopperOptions,
placement: 'bottom-end',
},
left: {
...sharedPopperOptions,
placement: 'bottom-start',
},
};
interface MenuProps {
className?: string;
children: React.ReactNode;
@@ -49,9 +30,7 @@ function Menu({
alignMenu = 'left',
enforceMaxHeight = true,
}: MenuProps) {
const updater = useRef<(() => void) | null>(null);
const menuButtonId = useId();
const menuContentId = useId();
const [maxHeight, setMaxHeight] = useState(0);
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -70,45 +49,14 @@ function Menu({
setMaxHeight(height);
}, [menuButtonId]);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const menuButton = document.getElementById(menuButtonId);
const handleMenuButtonPress = useCallback(() => {
setIsMenuOpen((isOpen) => !isOpen);
}, []);
if (!menuButton) {
return;
}
if (!menuButton.contains(event.target as Node)) {
setIsMenuOpen(false);
}
},
[menuButtonId]
);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
const menuButton = document.getElementById(menuButtonId);
const menuContent = document.getElementById(menuContentId);
if (!menuButton || !menuContent) {
return;
}
if (event.targetTouches.length !== 1) {
return;
}
const target = event.targetTouches[0].target;
if (
!menuButton.contains(target as Node) &&
!menuContent.contains(target as Node)
) {
setIsMenuOpen(false);
}
},
[menuButtonId, menuContentId]
);
const childrenArray = React.Children.toArray(children);
const button = React.cloneElement(childrenArray[0] as ReactElement, {
onPress: handleMenuButtonPress,
});
const handleWindowResize = useCallback(() => {
updateMaxHeight();
@@ -120,32 +68,15 @@ function Menu({
}
}, [isMenuOpen, updateMaxHeight]);
const handleMenuButtonPress = useCallback(() => {
setIsMenuOpen((isOpen) => !isOpen);
}, []);
const childrenArray = React.Children.toArray(children);
const button = React.cloneElement(childrenArray[0] as ReactElement, {
onPress: handleMenuButtonPress,
});
useEffect(() => {
if (enforceMaxHeight) {
updateMaxHeight();
}
}, [enforceMaxHeight, updateMaxHeight]);
useEffect(() => {
if (updater.current && isMenuOpen) {
updater.current();
}
}, [isMenuOpen]);
useEffect(() => {
// Listen to resize events on the window and scroll events
// on all elements to ensure the menu is the best size possible.
// Listen for click events on the window to support closing the
// menu on clicks outside.
if (!isMenuOpen) {
return;
@@ -153,52 +84,88 @@ function Menu({
window.addEventListener('resize', handleWindowResize);
window.addEventListener('scroll', handleWindowScroll, { capture: true });
window.addEventListener('click', handleWindowClick);
window.addEventListener('touchstart', handleTouchStart);
return () => {
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('scroll', handleWindowScroll, {
capture: true,
});
window.removeEventListener('click', handleWindowClick);
window.removeEventListener('touchstart', handleTouchStart);
};
}, [
isMenuOpen,
handleWindowResize,
handleWindowScroll,
handleWindowClick,
handleTouchStart,
}, [isMenuOpen, handleWindowResize, handleWindowScroll]);
const { refs, context, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
// offset({ mainAxis: 10 }),
shift(),
],
open: isMenuOpen,
placement: alignMenu === 'left' ? 'bottom-start' : 'bottom-end',
whileElementsMounted: autoUpdate,
onOpenChange: setIsMenuOpen,
});
const handleFloaterPress = useCallback(
(event: MouseEvent) => {
if (
refs.reference &&
(refs.reference.current as HTMLElement).contains(
event.target as HTMLElement
)
) {
return false;
}
// TODO: Menu items should handle closing when they are clicked.
// This is handled before the menu item click event is handled, so wait 100ms before closing.
setTimeout(() => {
setIsMenuOpen(false);
}, 100);
return true;
},
[refs.reference]
);
const click = useClick(context);
const dismiss = useDismiss(context, {
outsidePressEvent: 'click',
outsidePress: handleFloaterPress,
});
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
]);
return (
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={menuButtonId} className={className}>
{button}
</div>
)}
</Reference>
<>
<div
ref={refs.setReference}
{...getReferenceProps()}
id={menuButtonId}
className={className}
>
{button}
</div>
<Portal>
<Popper {...popperOptions[alignMenu]}>
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return React.cloneElement(childrenArray[1] as ReactElement, {
forwardedRef: ref,
style: {
...style,
maxHeight,
},
isOpen: isMenuOpen,
});
}}
</Popper>
</Portal>
</Manager>
{isMenuOpen ? (
<FloatingPortal id="portal-root">
{React.cloneElement(childrenArray[1] as ReactElement, {
forwardedRef: refs.setFloating,
style: {
maxHeight,
...floatingStyles,
},
isOpen: isMenuOpen,
...getFloatingProps(),
})}
</FloatingPortal>
) : null}
</>
);
}

View File

@@ -19,6 +19,7 @@
.modal {
position: relative;
display: flex;
max-width: 90%;
max-height: 90%;
border-radius: 6px;
opacity: 1;
@@ -88,13 +89,6 @@
}
@media only screen and (max-width: $breakpointMedium) {
.modal.small,
.modal.medium {
width: 90%;
}
}
@media only screen and (max-width: $breakpointSmall) {
.modalContainer {
position: fixed;
}

View File

@@ -1,5 +1,4 @@
.header {
z-index: 3;
display: flex;
align-items: center;
flex: 0 0 auto;

View File

@@ -13,10 +13,10 @@ import React, {
import Autosuggest from 'react-autosuggest';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useDebouncedCallback } from 'use-debounce';
import { Tag } from 'App/State/TagsAppState';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
import { icons } from 'Helpers/Props';
import Series from 'Series/Series';
@@ -316,7 +316,7 @@ function SeriesSearchInput() {
return;
}
// If an suggestion is not selected go to the first series,
// If a suggestion is not selected go to the first series,
// otherwise go to the selected series.
const selectedSuggestion =

View File

@@ -7,6 +7,40 @@
transform: translateX(0);
}
.sidebarHeader {
display: flex;
align-items: center;
justify-content: space-between;
height: $headerHeight;
}
.logoContainer {
display: flex;
align-items: center;
padding-left: 20px;
}
.logoLink {
line-height: 0;
}
.logo {
width: 32px;
height: 32px;
}
.sidebarCloseButton {
composes: button from '~Components/Link/IconButton.css';
margin-right: 15px;
color: #e1e2e3;
text-align: center;
&:hover {
color: var(--sonarrBlue);
}
}
.sidebar {
display: flex;
flex-direction: column;

View File

@@ -1,8 +1,13 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'logo': string;
'logoContainer': string;
'logoLink': string;
'sidebar': string;
'sidebarCloseButton': string;
'sidebarContainer': string;
'sidebarHeader': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,4 +1,3 @@
import classNames from 'classnames';
import React, {
useCallback,
useEffect,
@@ -11,6 +10,8 @@ import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router';
import QueueStatus from 'Activity/Queue/Status/QueueStatus';
import { IconName } from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import OverlayScroller from 'Components/Scroller/OverlayScroller';
import Scroller from 'Components/Scroller/Scroller';
import usePrevious from 'Helpers/Hooks/usePrevious';
@@ -230,10 +231,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
transition: 'none',
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
});
const [sidebarStyle, setSidebarStyle] = useState({
top: dimensions.headerHeight,
height: `${window.innerHeight - HEADER_HEIGHT}px`,
});
const urlBase = window.Sonarr.urlBase;
const pathname = urlBase
@@ -299,22 +296,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
}, [dispatch]);
const handleWindowScroll = useCallback(() => {
const windowScroll =
window.scrollY == null
? document.documentElement.scrollTop
: window.scrollY;
const sidebarTop = Math.max(HEADER_HEIGHT - windowScroll, 0);
const sidebarHeight = window.innerHeight - sidebarTop;
if (isSmallScreen) {
setSidebarStyle({
top: `${sidebarTop}px`,
height: `${sidebarHeight}px`,
});
}
}, [isSmallScreen]);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
const touches = event.touches;
@@ -359,44 +340,50 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
});
}, []);
const handleTouchEnd = useCallback((event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
const handleTouchEnd = useCallback(
(event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!touchStartX.current) {
return;
}
if (!touchStartX.current) {
return;
}
if (currentTouch > touchStartX.current && currentTouch > 50) {
setSidebarTransform({
transition: 'none',
transform: 0,
});
} else if (currentTouch < touchStartX.current && currentTouch < 80) {
setSidebarTransform({
transition: 'transform 50ms ease-in-out',
transform: SIDEBAR_WIDTH * -1,
});
} else {
setSidebarTransform({
transition: 'none',
transform: 0,
});
}
if (currentTouch > touchStartX.current && currentTouch > 50) {
setSidebarTransform({
transition: 'none',
transform: 0,
});
} else if (currentTouch < touchStartX.current && currentTouch < 80) {
setSidebarTransform({
transition: 'transform 50ms ease-in-out',
transform: SIDEBAR_WIDTH * -1,
});
} else {
setSidebarTransform({
transition: 'none',
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
});
}
touchStartX.current = null;
touchStartY.current = null;
}, []);
touchStartX.current = null;
touchStartY.current = null;
},
[isSidebarVisible]
);
const handleTouchCancel = useCallback(() => {
touchStartX.current = null;
touchStartY.current = null;
}, []);
const handleSidebarClosePress = useCallback(() => {
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
}, [dispatch]);
useEffect(() => {
if (isSmallScreen) {
window.addEventListener('click', handleWindowClick, { capture: true });
window.addEventListener('scroll', handleWindowScroll);
window.addEventListener('touchstart', handleTouchStart);
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
@@ -405,7 +392,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
return () => {
window.removeEventListener('click', handleWindowClick, { capture: true });
window.removeEventListener('scroll', handleWindowScroll);
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
@@ -414,7 +400,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
}, [
isSmallScreen,
handleWindowClick,
handleWindowScroll,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
@@ -453,13 +438,37 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
return (
<div
ref={sidebarRef}
className={classNames(styles.sidebarContainer)}
className={styles.sidebarContainer}
style={containerStyle}
>
{isSmallScreen ? (
<div className={styles.sidebarHeader}>
<div className={styles.logoContainer}>
<Link className={styles.logoLink} to="/">
<img
className={styles.logo}
src={`${window.Sonarr.urlBase}/Content/Images/logo.svg`}
alt="Sonarr Logo"
/>
</Link>
</div>
<IconButton
className={styles.sidebarCloseButton}
name={icons.CLOSE}
aria-label={translate('Close')}
size={20}
onPress={handleSidebarClosePress}
/>
</div>
) : null}
<ScrollerComponent
className={styles.sidebar}
scrollDirection="vertical"
style={sidebarStyle}
style={{
height: `${window.innerHeight - HEADER_HEIGHT}px`,
}}
>
<div>
{LINKS.map((link) => {

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

@@ -54,9 +54,7 @@ function PageSidebarItem({
</span>
)}
<span className={isChildItem ? styles.noIcon : undefined}>
{typeof title === 'function' ? title() : title}
</span>
{typeof title === 'function' ? title() : title}
{!!StatusComponent && (
<span className={styles.status}>

View File

@@ -22,11 +22,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

@@ -31,6 +31,7 @@ function PageToolbarButton({
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled || isSpinning}
title={label}
{...otherProps}
>
<Icon

View File

@@ -80,8 +80,12 @@ function PageToolbarSection({
if (buttonCount - 1 === maxButtons) {
const overflowItems: PageToolbarButtonProps[] = [];
const buttonsWithoutSeparators = validChildren.filter(
(child) => Object.keys(child.props).length > 0
);
return {
buttons: validChildren,
buttons: buttonsWithoutSeparators,
buttonCount,
overflowItems,
};

View File

@@ -3,19 +3,18 @@ import {
HubConnectionBuilder,
LogLevel,
} from '@microsoft/signalr';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import ModelBase from 'App/ModelBase';
import AppState from 'App/State/AppState';
import Command from 'Commands/Command';
import { setAppValue, setVersion } from 'Store/Actions/appActions';
import { removeItem, update, updateItem } from 'Store/Actions/baseActions';
import { removeItem, updateItem } from 'Store/Actions/baseActions';
import {
fetchCommands,
finishCommand,
updateCommand,
} from 'Store/Actions/commandActions';
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchSeries } from 'Store/Actions/seriesActions';
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
@@ -33,15 +32,13 @@ interface SignalRMessage {
resource: ModelBase;
version: string;
};
version: number | undefined;
}
function SignalRListener() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const isQueuePopulated = useSelector(
(state: AppState) => state.queue.paged.isPopulated
);
const connection = useRef<HubConnection | null>(null);
const handleStartFail = useRef((error: unknown) => {
@@ -97,9 +94,14 @@ function SignalRListener() {
});
const handleReceiveMessage = useRef((message: SignalRMessage) => {
console.debug('[signalR] received', message.name, message.body);
console.debug(
`[signalR] received ${message.name}${
message.version ? ` v${message.version}` : ''
}`,
message.body
);
const { name, body } = message;
const { name, body, version = 0 } = message;
if (name === 'calendar') {
if (body.action === 'updated') {
@@ -235,20 +237,36 @@ function SignalRListener() {
}
if (name === 'queue') {
if (isQueuePopulated) {
dispatch(fetchQueue());
if (version < 5) {
return;
}
queryClient.invalidateQueries({ queryKey: ['/queue'] });
return;
}
if (name === 'queue/details') {
dispatch(fetchQueueDetails());
if (version < 5) {
return;
}
queryClient.invalidateQueries({ queryKey: ['/queue/details'] });
return;
}
if (name === 'queue/status') {
dispatch(update({ section: 'queue.status', data: body.resource }));
if (version < 5) {
return;
}
const statusDetails = queryClient.getQueriesData({
queryKey: ['/queue/status'],
});
statusDetails.forEach(([queryKey]) => {
queryClient.setQueryData(queryKey, () => body.resource);
});
return;
}

View File

@@ -20,7 +20,6 @@ function RelativeDateCell(props: RelativeDateCellProps) {
date,
includeSeconds = false,
includeTime = false,
component: Component = TableRowCell,
...otherProps
} = props;

View File

@@ -4,7 +4,7 @@
line-height: 1.52857143;
}
@media only screen and (max-width: $breakpointSmall) {
@media only screen and (max-width: $breakpointMedium) {
.cell {
white-space: nowrap;
}

View File

@@ -7,7 +7,7 @@
white-space: nowrap;
}
@media only screen and (max-width: $breakpointSmall) {
@media only screen and (max-width: $breakpointMedium) {
.cell {
white-space: nowrap;
}

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