1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-05 13:20:20 -05:00

Compare commits

...

377 Commits

Author SHA1 Message Date
Mark McDowall
4ac61fc4c6 Remove v3 updates from UI 2025-10-06 06:48:38 +09:00
Mark McDowall
46c9b98fad Use react-query for Log Files 2025-10-06 06:41:42 +09:00
Mark McDowall
7ade5b1259 Add v5 log files endpoints 2025-10-04 21:04:12 -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
Stevie Robinson
e37684e045 Fixed: Failing dangerous and executable single file downloads 2025-01-25 18:29:07 -08:00
Stevie Robinson
103ccd74f3 New: Treat .scr as dangerous file
Closes #7588
2025-01-25 18:27:32 -08:00
Stevie Robinson
ba22992265 Fixed: Don't search for unmonitored specials when searching season
Closes #7589
2025-01-25 18:26:48 -08:00
Bogdan
963395b969 Prevent page crash on console.error being used with non-string values 2025-01-25 18:26:10 -08:00
Weblate
970df1a1d8 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Georgi Panov <darkfella91@gmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
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/fi/
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-01-25 18:26:02 -08:00
kephasdev
2ac139ab4d Fixed: Augmenting languages for releases with MULTI and other languages
(cherry picked from commit d58135bf1754b6185eef19a2f4069b27a918d01e)
2025-01-17 19:58:22 -08:00
Bogdan
c69db1ff92 New: Parsing titles with AKA separating multiple titles
Closes #7576
2025-01-17 19:57:52 -08:00
Bogdan
6dae2f0d84 Fixed: Images after series are updated via Series Editor 2025-01-17 19:57:13 -08:00
Bogdan
87934c7761 Fix typo in logging for custom format score 2025-01-17 19:55:57 -08:00
Bogdan
fe8478f42a Fix translation key for RSS in History Details 2025-01-17 19:55:57 -08:00
jcassette
a840bb5423 New: reflink support for ZFS 2025-01-17 19:55:37 -08:00
Weblate
8f5d628c55 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Florian Savouré <florian.savoure@gmail.com>
Co-authored-by: Georgi Panov <darkfella91@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: keysuck <joshkkim@gmail.com>
Co-authored-by: warkurre86 <tom.novo.86@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
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/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-01-17 19:55:19 -08:00
Mark McDowall
acebe87dba New: Parse releases with year and season number in brackets
Closes #7559
2025-01-10 17:06:40 -08:00
Mark McDowall
7d77500667 Fixed: Series being unmonitored when still in Import List
Closes #7555
2025-01-10 17:06:33 -08:00
Stevie Robinson
ec73a13396 Update translation widget 2025-01-10 17:06:19 -08:00
Stevie Robinson
fa0f77659c Additional logging for delay profile decisions
Closes #7558
2025-01-10 17:06:05 -08:00
Stevie Robinson
1609f0c964 New: Show release source in history grab popup 2025-01-10 17:05:46 -08:00
Bogdan
1fea0b3d10 Remote image links for Discord's manual interaction needed 2025-01-10 17:05:32 -08:00
Stevie Robinson
3c8268c428 Additional logging for custom format score 2025-01-10 17:05:23 -08:00
Mark McDowall
c589c4f85e Fixed: Tooltips for detailed error messages 2025-01-10 17:05:05 -08:00
Weblate
f843107c25 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: CaveMan474 <Caveman_TheLastOne@proton.me>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Lars <lars.erik.heloe@gmail.com>
Co-authored-by: Mickaël O <mickael.ouillon@ac-bordeaux.fr>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: RabSsS01 <royermatthieu78@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: mryx007 <mryx@mail.de>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
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/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-01-10 17:04:56 -08:00
Stevie Robinson
035c474f10 Fixed: Listening on all IPv4 Addresses
Closes #7526
2025-01-04 17:58:24 -08:00
Stevie Robinson
4dcc015fb1 Fixed: qBittorrent Ratio Limit Check
Closes #7527
2025-01-04 17:56:49 -08:00
Mark McDowall
1969e0107f Bump version to 4.0.12 2025-01-04 17:17:20 -08:00
Weblate
ac7c05c050 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <Ano10@users.noreply.translate.servarr.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Matti Meikäläinen <diefor-93@hotmail.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/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-01-04 17:17:10 -08:00
Bogdan
8aad79fd3e Check if backup folder is writable on backup 2024-12-30 21:16:30 -08:00
Bogdan
f05e552e8e Suggest adding IP to RPC whitelist for on failed Transmission auth 2024-12-30 21:16:16 -08:00
Mark McDowall
8cd5cd603a Fixed: Improve synchronization logic for import list items
Closes #7511
2024-12-30 21:16:02 -08:00
Weblate
ef358e6f24 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Alexander Balya <alexander.balya@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.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/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-12-30 21:15:52 -08:00
Mark McDowall
fae24e98fb Don't send session information to Sentry
Closes #7518
2024-12-28 02:12:33 +01:00
Harry Pollard
c885fb81f9 Fixed: Searching by title not using all titles 2024-12-26 11:40:35 -08:00
Bogdan
514c04935f Fixed: Advanced settings for Metadata consumers 2024-12-26 11:39:59 -08:00
Weblate
4b14368736 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: 1 <1228553526@qq.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Tommy Au <smarttommyau@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: marapavelka <mara.pavelka@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_TW/
Translation: Servarr/Sonarr
2024-12-26 11:39:50 -08:00
Mark McDowall
1c30ecd66d Fixed: Series updated during Import List Sync not reflected in the UI
Closes #7511
2024-12-22 21:59:12 -08:00
Bogdan
f7b54f9d6b Fixed: Prevent exception for seed configuration provider with invalid indexer ID 2024-12-22 21:59:05 -08:00
Weblate
ce7d8a175e Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Tommy Au <smarttommyau@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_TW/
Translation: Servarr/Sonarr
2024-12-22 21:58:44 -08:00
Bogdan
ab49268bac Bump NLog, IPAddressRange, Polly, ImageSharp, Npgsql, System.Memory, Ical.Net and Lib.Harmony 2024-12-20 16:15:58 -08:00
Bogdan
608f67a074 Bump MailKit to 4.8.0 and Microsoft.Data.SqlClient to 2.1.7 2024-12-20 16:15:58 -08:00
Mark McDowall
9a69222c9a Fixed: Prevent exception when grabbing unparsable release
Closes #7494
2024-12-20 16:15:48 -08:00
Weblate
82c526e15c Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-12-20 16:13:47 -08:00
Stevie Robinson
983b079c82 Fix: Adding a new root folder from edit series modal
Closes #7497
2024-12-20 16:13:30 -08:00
Mark McDowall
edfc12e27a Fixed: Loading calendar on older browsers 2024-12-16 20:57:56 -08:00
Mark McDowall
ed10b63fa0 Upgrade @typescript-eslint packages to 8.181.1 2024-12-16 20:57:48 -08:00
Mark McDowall
016b571838 Upgrade Font Awesome to 6.7.1 2024-12-16 20:57:48 -08:00
Mark McDowall
bfcd017012 Upgrade babel to 7.26.0 2024-12-16 20:57:48 -08:00
Bogdan
2e83d59f61 Set minor version for core-js in babel/preset-env 2024-12-16 20:57:34 -08:00
Bogdan
c39fb4fe6f Fix typo about download clients comment 2024-12-16 20:57:28 -08:00
Bogdan
220b4bc257 Fixed: Opening episode info modal on calendar event click 2024-12-16 20:57:28 -08:00
Weblate
99e25cec0f Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-12-16 20:57:20 -08:00
1600 changed files with 56963 additions and 55529 deletions

View File

@@ -2,11 +2,11 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
{
"name": "Sonarr",
"image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0",
"image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "16",
"version": "20",
"nvmVersion": "latest"
}
},

188
.github/actions/build/action.yml vendored Normal file
View File

@@ -0,0 +1,188 @@
name: Build Backend
description: Builds the backend and packages it
inputs:
branch:
description: "Branch name for this build"
required: true
version:
description: "Version number to build"
required: true
framework:
description: ".net framework used for the build"
required: true
runtime:
description: "Run time to build for"
required: true
package_tests:
description: "True if tests should be packaged for later testing steps"
runs:
using: "composite"
steps:
- name: Setup .NET
uses: actions/setup-dotnet@v5
- name: Setup Environment Variables
id: variables
shell: bash
run: |
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
echo "SONARR_VERSION=${{ inputs.version }}" >> "$GITHUB_ENV"
echo "BRANCH=${{ inputs.branch }}" >> "$GITHUB_ENV"
if [ "$RUNNER_OS" == "Windows" ]; then
echo "NUGET_PACKAGES=D:\nuget\packages" >> "$GITHUB_ENV"
fi
- name: Enable Extra Platforms In SDK
if: ${{ inputs.runtime == 'freebsd-x64' }}
shell: bash
run: |
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
if grep -q freebsd-x64 "$BUNDLEDVERSIONS"; then
echo "Extra platforms already enabled"
else
echo "Enabling extra platform support"
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS"
fi
if grep -qv freebsd-x64 src/Directory.Build.props; then
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64</RuntimeIdentifiers>^g" src/Directory.Build.props
fi
- name: Update Version Number
shell: bash
run: |
if [ "$SONARR_VERSION" != "" ]; then
echo "Updating version info to: $SONARR_VERSION"
sed -i'' -e "s/<AssemblyVersion>[0-9.*]\+<\/AssemblyVersion>/<AssemblyVersion>$SONARR_VERSION<\/AssemblyVersion>/g" src/Directory.Build.props
sed -i'' -e "s/<AssemblyConfiguration>[\$()A-Za-z-]\+<\/AssemblyConfiguration>/<AssemblyConfiguration>${BRANCH}<\/AssemblyConfiguration>/g" src/Directory.Build.props
sed -i'' -e "s/<string>10.0.0.0<\/string>/<string>$SONARR_VERSION<\/string>/g" distribution/macOS/Sonarr.app/Contents/Info.plist
fi
- name: Build Backend
shell: bash
run: |
runtime="${{ inputs.runtime }}"
platform=Windows
slnFile=src/Sonarr.sln
targetingWindows=false
IFS='-' read -ra SPLIT <<< "$runtime"
if [ "${SPLIT[0]}" == "win" ]; then
platform=Windows
targetingWindows=true
else
platform=Posix
fi
rm -rf _output
rm -rf _tests
echo "Building Sonarr for $runtime, Platform: $platform"
dotnet msbuild -restore $slnFile -p:SelfContained=True -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$runtime -p:EnableWindowsTargeting=true -t:PublishAllRids
- name: Package
shell: bash
run: |
framework="${{ inputs.framework }}"
runtime="${{ inputs.runtime }}"
IFS='-' read -ra SPLIT <<< "$runtime"
case "${SPLIT[0]}" in
linux|freebsd*)
folder=_artifacts/$runtime/$framework/Sonarr
echo "Packaging files"
rm -rf $folder
mkdir -p $folder
cp -r _output/$framework/$runtime/publish/* $folder
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
cp LICENSE.md $folder
echo "Removing Service helpers"
rm -f $folder/ServiceUninstall.*
rm -f $folder/ServiceInstall.*
echo "Removing Sonarr.Windows"
rm $folder/Sonarr.Windows.*
echo "Adding Sonarr.Mono to UpdatePackage"
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
;;
win)
folder=_artifacts/$runtime/$framework/Sonarr
echo "Packaging files"
rm -rf $folder
mkdir -p $folder
cp -r _output/$framework/$runtime/publish/* $folder
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
cp LICENSE.md $folder
cp -r _output/$framework-windows/$runtime/publish/* $folder
echo "Removing Sonarr.Mono"
rm -f $folder/Sonarr.Mono.*
rm -f $folder/Mono.Posix.NETStandard.*
rm -f $folder/libMonoPosixHelper.*
echo "Adding Sonarr.Windows to UpdatePackage"
cp $folder/Sonarr.Windows.* $folder/Sonarr.Update
;;
osx)
folder=_artifacts/$runtime/$framework/Sonarr
echo "Packaging files"
rm -rf $folder
mkdir -p $folder
cp -r _output/$framework/$runtime/publish/* $folder
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
cp LICENSE.md $folder
echo "Removing Service helpers"
rm -f $folder/ServiceUninstall.*
rm -f $folder/ServiceInstall.*
echo "Removing Sonarr.Windows"
rm $folder/Sonarr.Windows.*
echo "Adding Sonarr.Mono to UpdatePackage"
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
;;
esac
- name: Package Tests
if: ${{ inputs.package_tests }}
shell: bash
run: |
framework="${{ inputs.framework }}"
runtime="${{ inputs.runtime }}"
cp scripts/test.sh "_tests/$framework/$runtime/publish"
rm -f _tests/$framework/$runtime/*.log.config
- name: Upload Test Artifacts
if: ${{ inputs.package_tests }}
uses: ./.github/actions/publish-test-artifact
with:
framework: ${{ inputs.framework }}
runtime: ${{ inputs.runtime }}
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: build-${{ inputs.runtime }}
path: _artifacts/**/*

View File

@@ -2,34 +2,34 @@ name: Package
description: Packages binaries for deployment
inputs:
platform:
description: 'Binary platform'
runtime:
description: "Binary runtime"
required: true
framework:
description: '.net framework'
framework:
description: ".net framework"
required: true
artifact:
description: 'Binary artifact'
description: "Binary artifact"
required: true
branch:
description: 'Git branch used for this build'
description: "Git branch used for this build"
required: true
major_version:
description: 'Sonarr major version'
description: "Sonarr major version"
required: true
version:
description: 'Sonarr version'
description: "Sonarr version"
required: true
runs:
using: 'composite'
using: "composite"
steps:
- name: Download Artifact
uses: actions/download-artifact@v4
with:
name: ${{ inputs.artifact }}
path: _output
- name: Download UI Artifact
uses: actions/download-artifact@v4
with:
@@ -49,7 +49,7 @@ runs:
run: $GITHUB_ACTION_PATH/package.sh
- name: Create Windows Installer (x64)
if: ${{ inputs.platform == 'windows' }}
if: ${{ inputs.runtime == 'win-x64' }}
working-directory: distribution/windows/setup
shell: cmd
run: |
@@ -58,7 +58,7 @@ runs:
build.bat
- name: Create Windows Installer (x86)
if: ${{ inputs.platform == 'windows' }}
if: ${{ inputs.runtime == 'win-x86' }}
working-directory: distribution/windows/setup
shell: cmd
run: |
@@ -69,7 +69,7 @@ runs:
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: release_${{ inputs.platform }}
name: release-${{ inputs.runtime }}
compression-level: 0
if-no-files-found: error
path: |

View File

@@ -3,7 +3,7 @@
outputFolder=_output
artifactsFolder=_artifacts
uiFolder="$outputFolder/UI"
framework="${FRAMEWORK:=net6.0}"
framework="${FRAMEWORK:=net8.0}"
rm -rf $artifactsFolder
mkdir $artifactsFolder

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

@@ -1,12 +1,12 @@
name: 'API Docs'
name: "API Docs"
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 1'
- cron: "0 0 * * 1"
push:
branches:
- develop
- v5-develop
paths:
- ".github/workflows/api_docs.yml"
- "docs.sh"
@@ -33,7 +33,7 @@ jobs:
id: setup-dotnet
- name: Create openapi.json
run: ./docs.sh Linux
run: ./scripts/docs.sh Linux x64
- name: Commit API Docs Change
continue-on-error: true
@@ -46,7 +46,20 @@ jobs:
then
git commit -am 'Automated API Docs update' -m "ignore-downstream"
git push -f --set-upstream origin api-docs
curl -X POST -H "Authorization: Bearer ${{ secrets.OPENAPI_PAT }}" -H "Accept: application/vnd.github+json" https://api.github.com/repos/sonarr/sonarr/pulls -d '{"head":"api-docs","base":"develop","title":"Update API docs"}'
curl -X POST -H "Authorization: Bearer ${{ secrets.OPENAPI_PAT }}" -H "Accept: application/vnd.github+json" https://api.github.com/repos/sonarr/sonarr/pulls -d '{"head":"api-docs","base":"v5-develop","title":"Update API docs"}'
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

@@ -3,13 +3,13 @@ name: Build
on:
push:
branches:
- develop
- main
- v5-develop
- v5-main
paths-ignore:
- "src/Sonarr.Api.*/openapi.json"
pull_request:
branches:
- develop
- v5-develop
paths-ignore:
- "src/NzbDrone.Core/Localization/Core/**"
- "src/Sonarr.Api.*/openapi.json"
@@ -19,97 +19,85 @@ concurrency:
cancel-in-progress: true
env:
FRAMEWORK: net6.0
FRAMEWORK: net8.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 4
VERSION: 4.0.11
SONARR_MAJOR_VERSION: 5
VERSION: 5.0.0
jobs:
backend:
runs-on: windows-latest
prepare:
runs-on: ubuntu-latest
outputs:
framework: ${{ steps.variables.outputs.framework }}
major_version: ${{ steps.variables.outputs.major_version }}
version: ${{ steps.variables.outputs.version }}
branch: ${{ steps.variables.outputs.branch }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
- name: Setup Environment Variables
id: variables
shell: bash
run: |
# Add 800 to the build number because GitHub won't let us pick an arbitrary starting point
SONARR_VERSION="${{ env.VERSION }}.$((${{ github.run_number }}+800))"
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
echo "SONARR_VERSION=$SONARR_VERSION" >> "$GITHUB_ENV"
echo "BRANCH=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_ENV"
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT"
echo "version=${{ env.VERSION }}.$((${{ github.run_number }}))" >> "$GITHUB_OUTPUT"
echo "branch=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_OUTPUT"
- name: Enable Extra Platforms In SDK
shell: bash
run: ./build.sh --enable-extra-platforms-in-sdk
backend:
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- runtime: freebsd-x64
package_tests: false
os: ubuntu-latest
- runtime: linux-arm
package_tests: false
os: ubuntu-latest
- runtime: linux-arm64
package_tests: false
os: ubuntu-latest
- runtime: linux-musl-arm64
package_tests: false
os: ubuntu-latest
- runtime: linux-musl-x64
package_tests: false
os: ubuntu-latest
- runtime: linux-x64
package_tests: true
os: ubuntu-latest
- runtime: osx-arm64
package_tests: true
os: ubuntu-latest
- runtime: osx-x64
package_tests: false
os: ubuntu-latest
- runtime: win-x64
package_tests: true
os: ubuntu-latest
- runtime: win-x86
package_tests: false
os: ubuntu-latest
- name: Build Backend
shell: bash
run: ./build.sh --backend --enable-extra-platforms --packages
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v5
# Test Artifacts
- name: Publish win-x64 Test Artifact
uses: ./.github/actions/publish-test-artifact
- name: Build
uses: ./.github/actions/build
with:
framework: ${{ env.FRAMEWORK }}
runtime: win-x64
- name: Publish linux-x64 Test Artifact
uses: ./.github/actions/publish-test-artifact
with:
framework: ${{ env.FRAMEWORK }}
runtime: linux-x64
- name: Publish osx-arm64 Test Artifact
uses: ./.github/actions/publish-test-artifact
with:
framework: ${{ env.FRAMEWORK }}
runtime: osx-arm64
# Build Artifacts (grouped by OS)
- name: Publish FreeBSD Artifact
uses: actions/upload-artifact@v4
with:
name: build_freebsd
path: _artifacts/freebsd-*/**/*
- name: Publish Linux Artifact
uses: actions/upload-artifact@v4
with:
name: build_linux
path: _artifacts/linux-*/**/*
- name: Publish macOS Artifact
uses: actions/upload-artifact@v4
with:
name: build_macos
path: _artifacts/osx-*/**/*
- name: Publish Windows Artifact
uses: actions/upload-artifact@v4
with:
name: build_windows
path: _artifacts/win-*/**/*
branch: ${{ needs.prepare.outputs.branch }}
version: ${{ needs.prepare.outputs.version }}
framework: ${{ needs.prepare.outputs.framework }}
runtime: ${{ matrix.runtime }}
package_tests: ${{ matrix.package_tests }}
frontend:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Volta
uses: volta-cli/action@v4
@@ -151,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
@@ -164,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
@@ -176,9 +168,10 @@ 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: backend
needs: [prepare, backend]
strategy:
fail-fast: false
matrix:
@@ -187,22 +180,22 @@ jobs:
- os: ubuntu-latest
artifact: tests-linux-x64
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
binary_artifact: build_linux
binary_path: linux-x64/${{ needs.backend.outputs.framework }}/Sonarr
binary_artifact: build-linux-x64
binary_path: linux-x64/${{ needs.prepare.outputs.framework }}/Sonarr
- os: macos-latest
artifact: tests-osx-arm64
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
binary_artifact: build_macos
binary_path: osx-arm64/${{ needs.backend.outputs.framework }}/Sonarr
binary_artifact: build-osx-arm64
binary_path: osx-arm64/${{ needs.prepare.outputs.framework }}/Sonarr
- os: windows-latest
artifact: tests-win-x64
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory=IntegrationTest
binary_artifact: build_windows
binary_path: win-x64/${{ needs.backend.outputs.framework }}/Sonarr
binary_artifact: build-win-x64
binary_path: win-x64/${{ needs.prepare.outputs.framework }}/Sonarr
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Test
uses: ./.github/actions/test
@@ -216,20 +209,29 @@ jobs:
binary_path: ${{ matrix.binary_path }}
deploy:
if: ${{ github.ref_name == 'develop' || github.ref_name == 'main' }}
needs: [backend, frontend, unit_test, unit_test_postgres, integration_test]
if: ${{ github.ref_name == 'v5-develop-final' || github.ref_name == 'v5-main-final' }}
needs:
[
prepare,
backend,
frontend,
unit_test,
unit_test_postgres,
integration_test,
]
secrets: inherit
uses: ./.github/workflows/deploy.yml
with:
framework: ${{ needs.backend.outputs.framework }}
framework: ${{ needs.prepare.outputs.framework }}
branch: ${{ github.ref_name }}
major_version: ${{ needs.backend.outputs.major_version }}
version: ${{ needs.backend.outputs.version }}
major_version: ${{ needs.prepare.outputs.major_version }}
version: ${{ needs.prepare.outputs.version }}
notify:
name: Discord Notification
needs:
[
prepare,
backend,
frontend,
unit_test,
@@ -237,7 +239,7 @@ jobs:
integration_test,
deploy,
]
if: ${{ !cancelled() && (github.ref_name == 'develop' || github.ref_name == 'main') }}
if: ${{ !cancelled() && (github.ref_name == 'v5-develop-final' || github.ref_name == 'v5-main-final') }}
env:
STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }}
runs-on: ubuntu-latest
@@ -253,5 +255,5 @@ jobs:
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
embed-description: |
**Branch** ${{ github.ref }}
**Build** ${{ needs.backend.outputs.version }}
**Build** ${{ needs.prepare.outputs.version }}
embed-color: ${{ env.STATUS == 'success' && '3066993' || '15158332' }}

View File

@@ -7,6 +7,7 @@ on:
pull_request_target:
branches:
- develop
- v5-develop
types: [synchronize]
jobs:
@@ -21,6 +22,5 @@ jobs:
- name: Apply label
uses: eps1lon/actions-label-merge-conflict@v3
with:
dirtyLabel: 'merge-conflict'
repoToken: '${{ secrets.GITHUB_TOKEN }}'
dirtyLabel: "merge-conflict"
repoToken: "${{ secrets.GITHUB_TOKEN }}"

View File

@@ -3,20 +3,20 @@ name: Deploy
on:
workflow_call:
inputs:
framework:
description: '.net framework'
framework:
description: ".net framework"
type: string
required: true
branch:
description: 'Git branch used for this build'
description: "Git branch used for this build"
type: string
required: true
major_version:
description: 'Sonarr major version'
description: "Sonarr major version"
type: string
required: true
version:
description: 'Sonarr version'
description: "Sonarr version"
type: string
required: true
secrets:
@@ -27,31 +27,42 @@ jobs:
package:
strategy:
matrix:
platform: [freebsd, linux, macos, windows]
include:
- platform: freebsd
- runtime: freebsd-x64
os: ubuntu-latest
- platform: linux
- runtime: linux-arm
os: ubuntu-latest
- platform: macos
- runtime: linux-arm64
os: ubuntu-latest
- platform: windows
- runtime: linux-musl-arm64
os: ubuntu-latest
- runtime: linux-musl-x64
os: ubuntu-latest
- runtime: linux-x64
os: ubuntu-latest
- runtime: osx-arm64
os: ubuntu-latest
- runtime: osx-x64
os: ubuntu-latest
- runtime: win-x64
os: windows-latest
- runtime: win-x86
os: windows-latest
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: Check out
uses: actions/checkout@v4
- name: Package
uses: ./.github/actions/package
with:
framework: ${{ inputs.framework }}
platform: ${{ matrix.platform }}
artifact: build_${{ matrix.platform }}
branch: ${{ inputs.branch }}
major_version: ${{ inputs.major_version }}
version: ${{ inputs.version }}
- name: Package
uses: ./.github/actions/package
with:
framework: ${{ inputs.framework }}
runtime: ${{ matrix.runtime }}
artifact: build-${{ matrix.runtime }}
branch: ${{ inputs.branch }}
major_version: ${{ inputs.major_version }}
version: ${{ inputs.version }}
release:
needs: package
@@ -59,102 +70,102 @@ jobs:
permissions:
contents: write
steps:
- name: Check out
uses: actions/checkout@v4
- name: Check out
uses: actions/checkout@v4
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
path: _artifacts
pattern: release_*
merge-multiple: true
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
path: _artifacts
pattern: release-*
merge-multiple: true
- name: Get Previous Release
id: previous-release
uses: cardinalby/git-get-release-action@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
latest: true
prerelease: ${{ inputs.branch != 'main' }}
- name: Get Previous Release
id: previous-release
uses: cardinalby/git-get-release-action@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
latest: true
prerelease: ${{ inputs.branch != 'main' }}
- name: Generate Release Notes
id: generate-release-notes
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
result-encoding: string
script: |
const { data } = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: 'v${{ inputs.version }}',
target_commitish: '${{ github.sha }}',
previous_tag_name: '${{ steps.previous-release.outputs.tag_name }}',
})
return data.body
- name: Generate Release Notes
id: generate-release-notes
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
result-encoding: string
script: |
const { data } = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: 'v${{ inputs.version }}',
target_commitish: '${{ github.sha }}',
previous_tag_name: '${{ steps.previous-release.outputs.tag_name }}',
})
return data.body
- name: Create release
uses: ncipollo/release-action@v1
with:
artifacts: _artifacts/Sonarr.*
commit: ${{ github.sha }}
generateReleaseNotes: false
body: ${{ steps.generate-release-notes.outputs.result }}
name: ${{ inputs.version }}
prerelease: ${{ inputs.branch != 'main' }}
skipIfReleaseExists: true
tag: v${{ inputs.version }}
- name: Create release
uses: ncipollo/release-action@v1
with:
artifacts: _artifacts/Sonarr.*
commit: ${{ github.sha }}
generateReleaseNotes: false
body: ${{ steps.generate-release-notes.outputs.result }}
name: ${{ inputs.version }}
prerelease: ${{ inputs.branch != 'main' }}
skipIfReleaseExists: true
tag: v${{ inputs.version }}
- name: Publish to Services
shell: bash
working-directory: _artifacts
run: |
branch=${{ inputs.branch }}
version=${{ inputs.version }}
lastCommit=${{ github.sha }}
hashes="["
addHash() {
path=$1
os=$2
arch=$3
type=$4
local hash=$(sha256sum *.$version.$path | awk '{ print $1; }')
echo "{ \""Os\"": \""$os\"", \""Arch\"": \""$arch\"", \""Type\"": \""$type\"", \""Hash\"": \""$hash\"" }"
}
hashes="$hashes $(addHash "linux-arm.tar.gz" "linux" "arm" "archive")"
hashes="$hashes, $(addHash "linux-arm64.tar.gz" "linux" "arm64" "archive")"
hashes="$hashes, $(addHash "linux-x64.tar.gz" "linux" "x64" "archive")"
# hashes="$hashes, $(addHash "linux-x86.tar.gz" "linux" "x86" "archive")"
# hashes="$hashes, $(addHash "linux-musl-arm.tar.gz" "linuxmusl" "arm" "archive")"
hashes="$hashes, $(addHash "linux-musl-arm64.tar.gz" "linuxmusl" "arm64" "archive")"
hashes="$hashes, $(addHash "linux-musl-x64.tar.gz" "linuxmusl" "x64" "archive")"
hashes="$hashes, $(addHash "osx-arm64.tar.gz" "osx" "arm64" "archive")"
hashes="$hashes, $(addHash "osx-x64.tar.gz" "osx" "x64" "archive")"
hashes="$hashes, $(addHash "osx-arm64-app.zip" "osx" "arm64" "installer")"
hashes="$hashes, $(addHash "osx-x64-app.zip" "osx" "x64" "installer")"
hashes="$hashes, $(addHash "win-x64.zip" "windows" "x64" "archive")"
hashes="$hashes, $(addHash "win-x86.zip" "windows" "x86" "archive")"
hashes="$hashes, $(addHash "win-x64-installer.exe" "windows" "x64" "installer")"
hashes="$hashes, $(addHash "win-x86-installer.exe" "windows" "x86" "installer")"
hashes="$hashes, $(addHash "freebsd-x64.tar.gz" "freebsd" "x64" "archive")"
hashes="$hashes ]"
json="{\""branch\"":\""$branch\"", \""version\"":\""$version\"", \""lastCommit\"":\""$lastCommit\"", \""hashes\"":$hashes, \""gitHubRelease\"":true}"
url="https://services.sonarr.tv/v1/update"
echo "Publishing update $version ($branch) to: $url"
echo "$json"
curl -H "Content-Type: application/json" -H "X-Api-Key: ${{ secrets.SERVICES_API_KEY }}" -X POST -d "$json" --fail-with-body $url
- name: Publish to Services
shell: bash
working-directory: _artifacts
run: |
branch=${{ inputs.branch }}
version=${{ inputs.version }}
lastCommit=${{ github.sha }}
hashes="["
addHash() {
path=$1
os=$2
arch=$3
type=$4
local hash=$(sha256sum *.$version.$path | awk '{ print $1; }')
echo "{ \""Os\"": \""$os\"", \""Arch\"": \""$arch\"", \""Type\"": \""$type\"", \""Hash\"": \""$hash\"" }"
}
hashes="$hashes $(addHash "linux-arm.tar.gz" "linux" "arm" "archive")"
hashes="$hashes, $(addHash "linux-arm64.tar.gz" "linux" "arm64" "archive")"
hashes="$hashes, $(addHash "linux-x64.tar.gz" "linux" "x64" "archive")"
# hashes="$hashes, $(addHash "linux-x86.tar.gz" "linux" "x86" "archive")"
# hashes="$hashes, $(addHash "linux-musl-arm.tar.gz" "linuxmusl" "arm" "archive")"
hashes="$hashes, $(addHash "linux-musl-arm64.tar.gz" "linuxmusl" "arm64" "archive")"
hashes="$hashes, $(addHash "linux-musl-x64.tar.gz" "linuxmusl" "x64" "archive")"
hashes="$hashes, $(addHash "osx-arm64.tar.gz" "osx" "arm64" "archive")"
hashes="$hashes, $(addHash "osx-x64.tar.gz" "osx" "x64" "archive")"
hashes="$hashes, $(addHash "osx-arm64-app.zip" "osx" "arm64" "installer")"
hashes="$hashes, $(addHash "osx-x64-app.zip" "osx" "x64" "installer")"
hashes="$hashes, $(addHash "win-x64.zip" "windows" "x64" "archive")"
hashes="$hashes, $(addHash "win-x86.zip" "windows" "x86" "archive")"
hashes="$hashes, $(addHash "win-x64-installer.exe" "windows" "x64" "installer")"
hashes="$hashes, $(addHash "win-x86-installer.exe" "windows" "x86" "installer")"
hashes="$hashes, $(addHash "freebsd-x64.tar.gz" "freebsd" "x64" "archive")"
hashes="$hashes ]"
json="{\""branch\"":\""$branch\"", \""version\"":\""$version\"", \""lastCommit\"":\""$lastCommit\"", \""hashes\"":$hashes, \""gitHubRelease\"":true}"
url="https://services.sonarr.tv/v1/update"
echo "Publishing update $version ($branch) to: $url"
echo "$json"
curl -H "Content-Type: application/json" -H "X-Api-Key: ${{ secrets.SERVICES_API_KEY }}" -X POST -d "$json" --fail-with-body $url

2
.vscode/launch.json vendored
View File

@@ -10,7 +10,7 @@
"request": "launch",
"preLaunchTask": "build dotnet",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/_output/net6.0/Sonarr",
"program": "${workspaceFolder}/_output/net8.0/Sonarr",
"args": [],
"cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console

View File

@@ -1,32 +1,35 @@
# How to Contribute #
# How to Contribute
We're always looking for people to help make Sonarr even better, there are a number of ways to contribute.
## Documentation ##
## Documentation
Setup guides, [FAQ](https://wiki.servarr.com/sonarr/faq), the more information we have on the [wiki](https://wiki.servarr.com/sonarr) the better.
## Development ##
## Development
### Tools required ###
- Visual Studio 2019 or higher (https://www.visualstudio.com/vs/). The community version is free and works (https://www.visualstudio.com/downloads/).
### Tools required
- Visual Studio 2019 or higher (https://www.visualstudio.com/vs/). The community version is free and works (https://www.visualstudio.com/downloads/).
- HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc)
- [Git](https://git-scm.com/downloads)
- [NodeJS](https://nodejs.org/en/download/) (Node 10.X.X or higher)
- [Yarn](https://yarnpkg.com/)
### Getting started ###
### Getting started
1. Fork Sonarr
2. Clone the repository into your development machine. [*info*](https://docs.github.com/en/get-started/quickstart/fork-a-repo)
2. Clone the repository into your development machine. [_info_](https://docs.github.com/en/get-started/quickstart/fork-a-repo)
3. Install the required Node Packages `yarn install`
4. Start webpack to monitor your dev environment for any frontend changes that need post processing using `yarn start` command.
5. Build the project in Visual Studio, Setting startup project to `Sonarr.Console` and framework to `x86`
6. Debug the project in Visual Studio
7. Open http://localhost:8989
### Contributing Code ###
### Contributing Code
- If you're adding a new, already requested feature, please comment on [Github Issues](https://github.com/Sonarr/Sonarr/issues "Github Issues") so work is not duplicated (If you want to add something not already on there, please talk to us first)
- Rebase from Sonarr's `develop` branch, don't merge
- Rebase from Sonarr's `v5-develop` branch, don't merge
- Make meaningful commits, or squash them
- Feel free to make a pull request before work is complete, this will let us see where its at and make comments/suggest improvements
- Reach out to us on our [forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/), [discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr) if you have any questions
@@ -35,8 +38,9 @@ Setup guides, [FAQ](https://wiki.servarr.com/sonarr/faq), the more information w
- One feature/bug fix per pull request to keep things clean and easy to understand
- Use 4 spaces instead of tabs, this should be the default for VS 2019 and WebStorm
### Pull Requesting ###
- Only make pull requests to develop (currently `develop`), never `main`, if you make a PR to master we'll comment on it and close it
### Pull Requesting
- Only make pull requests to the default branch (currently `v5-develop`), never `main`, if you make a PR to main we'll comment on it and close it
- You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability
- We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it
- Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed)

View File

@@ -1,6 +1,6 @@
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
[![Translated](https://translate.servarr.com/widgets/servarr/-/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/)
[![Translated](https://translate.servarr.com/widget/servarr/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/)
[![Backers on Open Collective](https://opencollective.com/Sonarr/backers/badge.svg)](#backers)
[![Sponsors on Open Collective](https://opencollective.com/Sonarr/sponsors/badge.svg)](#sponsors)
[![Mega Sponsors on Open Collective](https://opencollective.com/Sonarr/megasponsors/badge.svg)](#mega-sponsors)
@@ -12,7 +12,7 @@ Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS fee
- [Download/Installation](https://sonarr.tv/#downloads-v3)
- [FAQ](https://wiki.servarr.com/sonarr/faq)
- [Wiki](https://wiki.servarr.com/Sonarr)
- [v4 Beta API Documentation](https://sonarr.tv/docs/api)
- [API Documentation](https://sonarr.tv/docs/api)
- [Donate](https://sonarr.tv/donate)
## Support
@@ -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

455
build.sh
View File

@@ -1,455 +0,0 @@
#! /usr/bin/env bash
set -e
outputFolder='_output'
testPackageFolder='_tests'
artifactsFolder="_artifacts";
framework="${FRAMEWORK:=net6.0}"
ProgressStart()
{
echo "::group::$1"
echo "Start '$1'"
}
ProgressEnd()
{
echo "Finish '$1'"
echo "::endgroup::"
}
UpdateVersionNumber()
{
if [ "$SONARR_VERSION" != "" ]; then
echo "Updating version info to: $SONARR_VERSION"
sed -i'' -e "s/<AssemblyVersion>[0-9.*]\+<\/AssemblyVersion>/<AssemblyVersion>$SONARR_VERSION<\/AssemblyVersion>/g" src/Directory.Build.props
sed -i'' -e "s/<AssemblyConfiguration>[\$()A-Za-z-]\+<\/AssemblyConfiguration>/<AssemblyConfiguration>${BRANCH}<\/AssemblyConfiguration>/g" src/Directory.Build.props
sed -i'' -e "s/<string>10.0.0.0<\/string>/<string>$SONARR_VERSION<\/string>/g" distribution/macOS/Sonarr.app/Contents/Info.plist
fi
}
EnableExtraPlatformsInSDK()
{
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
if grep -q freebsd-x64 "$BUNDLEDVERSIONS"; then
echo "Extra platforms already enabled"
else
echo "Enabling extra platform support"
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS"
fi
}
EnableExtraPlatforms()
{
if grep -qv freebsd-x64 src/Directory.Build.props; then
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64</RuntimeIdentifiers>^g" src/Directory.Build.props
fi
}
LintUI()
{
ProgressStart 'ESLint'
yarn lint
ProgressEnd 'ESLint'
ProgressStart 'Stylelint'
yarn stylelint
ProgressEnd 'Stylelint'
}
Build()
{
ProgressStart 'Build'
rm -rf $outputFolder
rm -rf $testPackageFolder
slnFile=src/Sonarr.sln
if [ $os = "windows" ]; then
platform=Windows
else
platform=Posix
fi
dotnet clean $slnFile -c Debug
dotnet clean $slnFile -c Release
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
then
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -t:PublishAllRids
else
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$RID -t:PublishAllRids
fi
ProgressEnd 'Build'
}
YarnInstall()
{
ProgressStart 'yarn install'
yarn install --frozen-lockfile --network-timeout 120000
ProgressEnd 'yarn install'
}
RunWebpack()
{
ProgressStart 'Running webpack'
yarn run build --env production
ProgressEnd 'Running webpack'
}
PackageFiles()
{
local folder="$1"
local framework="$2"
local runtime="$3"
rm -rf $folder
mkdir -p $folder
cp -r $outputFolder/$framework/$runtime/publish/* $folder
cp -r $outputFolder/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
if [ "$FRONTEND" = "YES" ];
then
cp -r $outputFolder/UI $folder
fi
echo "Adding LICENSE"
cp LICENSE.md $folder
}
PackageLinux()
{
local framework="$1"
local runtime="$2"
ProgressStart "Creating $runtime Package for $framework"
local folder=$artifactsFolder/$runtime/$framework/Sonarr
PackageFiles "$folder" "$framework" "$runtime"
echo "Removing Service helpers"
rm -f $folder/ServiceUninstall.*
rm -f $folder/ServiceInstall.*
echo "Removing Sonarr.Windows"
rm $folder/Sonarr.Windows.*
echo "Adding Sonarr.Mono to UpdatePackage"
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
if [ "$framework" = "$framework" ]; then
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
fi
ProgressEnd "Creating $runtime Package for $framework"
}
PackageMacOS()
{
local framework="$1"
local runtime="$2"
ProgressStart "Creating $runtime Package for $framework"
local folder=$artifactsFolder/$runtime/$framework/Sonarr
PackageFiles "$folder" "$framework" "$runtime"
echo "Removing Service helpers"
rm -f $folder/ServiceUninstall.*
rm -f $folder/ServiceInstall.*
echo "Removing Sonarr.Windows"
rm $folder/Sonarr.Windows.*
echo "Adding Sonarr.Mono to UpdatePackage"
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
if [ "$framework" = "$framework" ]; then
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
fi
ProgressEnd "Creating $runtime Package for $framework"
}
PackageMacOSApp()
{
local framework="$1"
local runtime="$2"
ProgressStart "Creating $runtime App Package for $framework"
local folder=$artifactsFolder/$runtime-app/$framework
rm -rf $folder
mkdir -p $folder
cp -r distribution/macOS/Sonarr.app $folder
mkdir -p $folder/Sonarr.app/Contents/MacOS
echo "Copying Binaries"
cp -r $artifactsFolder/$runtime/$framework/Sonarr/* $folder/Sonarr.app/Contents/MacOS
echo "Removing Update Folder"
rm -r $folder/Sonarr.app/Contents/MacOS/Sonarr.Update
ProgressEnd "Creating $runtime App Package for $framework"
}
PackageWindows()
{
local framework="$1"
local runtime="$2"
ProgressStart "Creating Windows Package for $framework"
local folder=$artifactsFolder/$runtime/$framework/Sonarr
PackageFiles "$folder" "$framework" "$runtime"
cp -r $outputFolder/$framework-windows/$runtime/publish/* $folder
echo "Removing Sonarr.Mono"
rm -f $folder/Sonarr.Mono.*
rm -f $folder/Mono.Posix.NETStandard.*
rm -f $folder/libMonoPosixHelper.*
echo "Adding Sonarr.Windows to UpdatePackage"
cp $folder/Sonarr.Windows.* $folder/Sonarr.Update
ProgressEnd "Creating Windows Package for $framework"
}
Package()
{
local framework="$1"
local runtime="$2"
local SPLIT
IFS='-' read -ra SPLIT <<< "$runtime"
case "${SPLIT[0]}" in
linux|freebsd*)
PackageLinux "$framework" "$runtime"
;;
win)
PackageWindows "$framework" "$runtime"
;;
osx)
PackageMacOS "$framework" "$runtime"
;;
esac
}
PackageTests()
{
local framework="$1"
local runtime="$2"
ProgressStart "Creating $runtime Test Package for $framework"
cp test.sh "$testPackageFolder/$framework/$runtime/publish"
rm -f $testPackageFolder/$framework/$runtime/*.log.config
ProgressEnd "Creating $runtime Test Package for $framework"
}
UploadTestArtifacts()
{
local framework="$1"
ProgressStart 'Publishing Test Artifacts'
# Tests
for dir in $testPackageFolder/$framework/*
do
local runtime=$(basename "$dir")
echo "##teamcity[publishArtifacts '$testPackageFolder/$framework/$runtime/publish/** => tests.$runtime.zip']"
done
ProgressEnd 'Publishing Test Artifacts'
}
UploadArtifacts()
{
local framework="$1"
ProgressStart 'Publishing Artifacts'
# Releases
for dir in $artifactsFolder/*
do
local runtime=$(basename "$dir")
echo "##teamcity[publishArtifacts '$artifactsFolder/$runtime/$framework/** => Sonarr.$BRANCH.$SONARR_VERSION.$runtime.zip']"
done
# Debian Package / Windows installer / macOS app
echo "##teamcity[publishArtifacts 'distribution/** => distribution.zip']"
ProgressEnd 'Publishing Artifacts'
}
UploadUIArtifacts()
{
local framework="$1"
ProgressStart 'Publishing UI Artifacts'
# UI folder
echo "##teamcity[publishArtifacts '$outputFolder/UI/** => UI.zip']"
ProgressEnd 'Publishing UI Artifacts'
}
# Use mono or .net depending on OS
case "$(uname -s)" in
CYGWIN*|MINGW32*|MINGW64*|MSYS*)
# on windows, use dotnet
os="windows"
;;
*)
# otherwise use mono
os="posix"
;;
esac
POSITIONAL=()
if [ $# -eq 0 ]; then
echo "No arguments provided, building everything"
BACKEND=YES
FRONTEND=YES
PACKAGES=YES
LINT=YES
ENABLE_EXTRA_PLATFORMS=NO
ENABLE_EXTRA_PLATFORMS_IN_SDK=NO
fi
while [[ $# -gt 0 ]]
do
key="$1"
case $key in
--backend)
BACKEND=YES
shift # past argument
;;
--enable-bsd|--enable-extra-platforms)
ENABLE_EXTRA_PLATFORMS=YES
shift # past argument
;;
--enable-extra-platforms-in-sdk)
ENABLE_EXTRA_PLATFORMS_IN_SDK=YES
shift # past argument
;;
-r|--runtime)
RID="$2"
shift # past argument
shift # past value
;;
-f|--framework)
FRAMEWORK="$2"
shift # past argument
shift # past value
;;
--frontend)
FRONTEND=YES
shift # past argument
;;
--packages)
PACKAGES=YES
shift # past argument
;;
--lint)
LINT=YES
shift # past argument
;;
--all)
BACKEND=YES
FRONTEND=YES
PACKAGES=YES
LINT=YES
shift # past argument
;;
*) # unknown option
POSITIONAL+=("$1") # save it in an array for later
shift # past argument
;;
esac
done
set -- "${POSITIONAL[@]}" # restore positional parameters
if [ "$ENABLE_EXTRA_PLATFORMS_IN_SDK" = "YES" ];
then
EnableExtraPlatformsInSDK
fi
if [ "$BACKEND" = "YES" ];
then
UpdateVersionNumber
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
then
EnableExtraPlatforms
fi
Build
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
then
PackageTests "$framework" "win-x64"
PackageTests "$framework" "win-x86"
PackageTests "$framework" "linux-x64"
PackageTests "$framework" "linux-musl-x64"
PackageTests "$framework" "osx-x64"
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
then
PackageTests "$framework" "freebsd-x64"
fi
else
PackageTests "$FRAMEWORK" "$RID"
fi
UploadTestArtifacts "$framework"
fi
if [ "$FRONTEND" = "YES" ];
then
YarnInstall
if [ "$LINT" = "YES" ];
then
LintUI
fi
RunWebpack
UploadUIArtifacts
fi
if [ "$PACKAGES" = "YES" ];
then
UpdateVersionNumber
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
then
Package "$framework" "win-x64"
Package "$framework" "win-x86"
Package "$framework" "linux-x64"
Package "$framework" "linux-musl-x64"
Package "$framework" "linux-arm64"
Package "$framework" "linux-musl-arm64"
Package "$framework" "linux-arm"
Package "$framework" "osx-x64"
Package "$framework" "osx-arm64"
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
then
Package "$framework" "freebsd-x64"
fi
else
Package "$FRAMEWORK" "$RID"
fi
UploadArtifacts "$framework"
fi

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

@@ -1,7 +1,7 @@
@REM SET SONARR_MAJOR_VERSION=4
@REM SET SONARR_VERSION=4.0.0.5
@REM SET BRANCH=develop
@REM SET FRAMEWORK=net6.0
@REM SET FRAMEWORK=net8.0
@REM SET RUNTIME=win-x64
inno\ISCC.exe sonarr.iss

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

@@ -363,7 +363,11 @@ module.exports = {
{
args: 'after-used',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
}
],
'@typescript-eslint/explicit-function-return-type': 'off',

View File

@@ -14,7 +14,6 @@ module.exports = (env) => {
const srcFolder = path.join(frontendFolder, 'src');
const isProduction = !!env.production;
const isProfiling = isProduction && !!env.profile;
const inlineWebWorkers = 'no-fallback';
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
@@ -66,7 +65,7 @@ module.exports = (env) => {
output: {
path: distFolder,
publicPath: '/',
publicPath: 'auto',
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
sourceMapFilename: '[file].map'
},
@@ -160,16 +159,6 @@ module.exports = (env) => {
module: {
rules: [
{
test: /\.worker\.js$/,
use: {
loader: 'worker-loader',
options: {
filename: '[name].js',
inline: inlineWebWorkers
}
}
},
{
test: [/\.jsx?$/, /\.tsx?$/],
exclude: /(node_modules|JsLibraries)/,
@@ -187,7 +176,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: 3
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) => {
dispatch(setBlocklistFilter({ selectedFilterKey }));
(selectedFilterKey: string | number) => {
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,53 +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 from 'Components/Filter/FilterModal';
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setBlocklistOption } from './blocklistOptionsStore';
import useBlocklist, { FILTER_BUILDER } from './useBlocklist';
function createBlocklistSelector() {
return createSelector(
(state: AppState) => state.blocklist.items,
(blocklistItems) => {
return blocklistItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.blocklist.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
interface BlocklistFilterModalProps {
isOpen: boolean;
}
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
// TODO: Don't spread all the props
{...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

@@ -18,6 +18,7 @@ import {
} from 'typings/History';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './HistoryDetails.css';
@@ -41,6 +42,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
indexer,
releaseGroup,
seriesMatchType,
releaseSource,
customFormatScore,
nzbInfoUrl,
downloadClient,
@@ -49,10 +51,36 @@ function HistoryDetails(props: HistoryDetailsProps) {
ageHours,
ageMinutes,
publishedDate,
size,
} = data as GrabbedHistoryData;
const downloadClientNameInfo = downloadClientName ?? downloadClient;
let releaseSourceMessage = '';
switch (releaseSource) {
case 'Unknown':
releaseSourceMessage = translate('Unknown');
break;
case 'Rss':
releaseSourceMessage = translate('Rss');
break;
case 'Search':
releaseSourceMessage = translate('Search');
break;
case 'UserInvokedSearch':
releaseSourceMessage = translate('UserInvokedSearch');
break;
case 'InteractiveSearch':
releaseSourceMessage = translate('InteractiveSearch');
break;
case 'ReleasePush':
releaseSourceMessage = translate('ReleasePush');
break;
default:
releaseSourceMessage = '';
}
return (
<DescriptionList>
<DescriptionListItem
@@ -88,6 +116,14 @@ function HistoryDetails(props: HistoryDetailsProps) {
/>
) : null}
{releaseSource ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseSource')}
data={releaseSourceMessage}
/>
) : null}
{nzbInfoUrl ? (
<span>
<DescriptionListItemTitle>
@@ -126,12 +162,19 @@ function HistoryDetails(props: HistoryDetailsProps) {
})}
/>
) : null}
{size ? (
<DescriptionListItem
title={translate('Size')}
data={formatBytes(size)}
/>
) : null}
</DescriptionList>
);
}
if (eventType === 'downloadFailed') {
const { message } = data as DownloadFailedHistory;
const { indexer, message, source } = data as DownloadFailedHistory;
return (
<DescriptionList>
@@ -145,15 +188,23 @@ 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>
);
}
if (eventType === 'downloadFolderImported') {
const { customFormatScore, droppedPath, importedPath } =
const { customFormatScore, droppedPath, importedPath, size } =
data as DownloadFolderImportedHistory;
return (
@@ -186,12 +237,20 @@ function HistoryDetails(props: HistoryDetailsProps) {
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
{size ? (
<DescriptionListItem
title={translate('FileSize')}
data={formatBytes(size)}
/>
) : null}
</DescriptionList>
);
}
if (eventType === 'episodeFileDeleted') {
const { reason, customFormatScore } = data as EpisodeFileDeletedHistory;
const { reason, customFormatScore, size } =
data as EpisodeFileDeletedHistory;
let reasonMessage = '';
@@ -221,6 +280,13 @@ function HistoryDetails(props: HistoryDetailsProps) {
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
{size ? (
<DescriptionListItem
title={translate('FileSize')}
data={formatBytes(size)}
/>
) : null}
</DescriptionList>
);
}

View File

@@ -80,7 +80,7 @@ function History() {
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string) => {
(selectedFilterKey: string | number) => {
dispatch(setHistoryFilter({ selectedFilterKey }));
},
[dispatch]

View File

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setHistoryFilter } from 'Store/Actions/historyActions';
function createHistorySelector() {
@@ -23,19 +23,16 @@ function createFilterBuilderPropsSelector() {
);
}
interface HistoryFilterModalProps {
isOpen: boolean;
}
type HistoryFilterModalProps = FilterModalProps<History>;
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
const sectionItems = useSelector(createHistorySelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'history';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
(payload: { selectedFilterKey: string | number }) => {
dispatch(setHistoryFilter(payload));
},
[dispatch]
@@ -43,11 +40,10 @@ export default function HistoryFilterModal(props: HistoryFilterModalProps) {
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
customFilterType="history"
dispatchSetFilter={dispatchSetFilter}
/>
);

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

@@ -1,7 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'dragPreview': string;
'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) => {
dispatch(setQueueFilter({ selectedFilterKey }));
(selectedFilterKey: string | number) => {
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,53 +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 from 'Components/Filter/FilterModal';
import { setQueueFilter } from 'Store/Actions/queueActions';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setQueueOption } from './queueOptionsStore';
import useQueue, { FILTER_BUILDER } from './useQueue';
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;
}
);
}
interface QueueFilterModalProps {
isOpen: boolean;
}
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
// TODO: Don't spread all the props
{...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 { CheckInputChanged } 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 }: CheckInputChanged) => {
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

@@ -1,5 +1,5 @@
import React from 'react';
import Icon, { IconProps } from 'Components/Icon';
import Icon, { IconKind } from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds } from 'Helpers/Props';
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
// status === 'downloading'
let iconName = icons.DOWNLOADING;
let iconKind: IconProps['kind'] = kinds.DEFAULT;
let iconKind: IconKind = kinds.DEFAULT;
let title = translate('Downloading');
if (status === 'paused') {
@@ -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,215 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
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 { icons, kinds } from 'Helpers/Props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector';
import styles from './AddNewSeries.css';
class AddNewSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
term: props.term || '',
isFetching: false
};
}
componentDidMount() {
const term = this.state.term;
if (term) {
this.props.onSeriesLookupChange(term);
}
}
componentDidUpdate(prevProps) {
const {
term,
isFetching
} = this.props;
if (term && term !== prevProps.term) {
this.setState({
term,
isFetching: true
});
this.props.onSeriesLookupChange(term);
} else if (isFetching !== prevProps.isFetching) {
this.setState({
isFetching
});
}
}
//
// Listeners
onSearchInputChange = ({ value }) => {
const hasValue = !!value.trim();
this.setState({ term: value, isFetching: hasValue }, () => {
if (hasValue) {
this.props.onSeriesLookupChange(value);
} else {
this.props.onClearSeriesLookup();
}
});
};
onClearSeriesLookupPress = () => {
this.setState({ term: '' });
this.props.onClearSeriesLookup();
};
//
// Render
render() {
const {
error,
items,
hasExistingSeries
} = this.props;
const term = this.state.term;
const isFetching = this.state.isFetching;
return (
<PageContent title={translate('AddNewSeries')}>
<PageContentBody>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon
name={icons.SEARCH}
size={20}
/>
</div>
<TextInput
className={styles.searchInput}
name="seriesLookup"
value={term}
placeholder="eg. Breaking Bad, tvdb:####"
autoFocus={true}
onChange={this.onSearchInputChange}
/>
<Button
className={styles.clearLookupButton}
onPress={this.onClearSeriesLookupPress}
>
<Icon
name={icons.REMOVE}
size={20}
/>
</Button>
</div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error ?
<div className={styles.message}>
<div className={styles.helpText}>
{translate('AddNewSeriesError')}
</div>
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
</div> : null
}
{
!isFetching && !error && !!items.length &&
<div className={styles.searchResults}>
{
items.map((item) => {
return (
<AddNewSeriesSearchResultConnector
key={item.tvdbId}
{...item}
/>
);
})
}
</div>
}
{
!isFetching && !error && !items.length && !!term &&
<div className={styles.message}>
<div className={styles.noResults}>{translate('CouldNotFindResults', { term })}</div>
<div>{translate('SearchByTvdbId')}</div>
<div>
<Link to="https://wiki.servarr.com/sonarr/faq#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
{translate('WhyCantIFindMyShow')}
</Link>
</div>
</div>
}
{
term ?
null :
<div className={styles.message}>
<div className={styles.helpText}>
{translate('AddNewSeriesHelpText')}
</div>
<div>{translate('SearchByTvdbId')}</div>
</div>
}
{
!term && !hasExistingSeries ?
<div className={styles.message}>
<div className={styles.noSeriesText}>
{translate('NoSeriesHaveBeenAdded')}
</div>
<div>
<Button
to="/add/import"
kind={kinds.PRIMARY}
>
{translate('ImportExistingSeries')}
</Button>
</div>
</div> :
null
}
<div />
</PageContentBody>
</PageContent>
);
}
}
AddNewSeries.propTypes = {
term: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
hasExistingSeries: PropTypes.bool.isRequired,
onSeriesLookupChange: PropTypes.func.isRequired,
onClearSeriesLookup: PropTypes.func.isRequired
};
export default AddNewSeries;

View File

@@ -0,0 +1,149 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
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 useDebounce from 'Helpers/Hooks/useDebounce';
import useQueryParams from 'Helpers/Hooks/useQueryParams';
import { icons, kinds } from 'Helpers/Props';
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() {
const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
const seriesCount = useSelector(
(state: AppState) => state.series.items.length
);
const [term, setTerm] = useState(initialTerm);
const [isFetching, setIsFetching] = useState(false);
const query = useDebounce(term, term ? 300 : 0);
const handleSearchInputChange = useCallback(
({ value }: InputChanged<string>) => {
setTerm(value);
setIsFetching(!!value.trim());
},
[]
);
const handleClearSeriesLookupPress = useCallback(() => {
setTerm('');
setIsFetching(false);
}, []);
const {
isFetching: isFetchingApi,
error,
data = [],
} = useLookupSeries(query);
useEffect(() => {
setIsFetching(isFetchingApi);
}, [isFetchingApi]);
useEffect(() => {
setTerm(initialTerm);
}, [initialTerm]);
return (
<PageContent title={translate('AddNewSeries')}>
<PageContentBody>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} size={20} />
</div>
<TextInput
className={styles.searchInput}
name="seriesLookup"
value={term}
placeholder="eg. Breaking Bad, tvdb:####"
autoFocus={true}
onChange={handleSearchInputChange}
/>
<Button
className={styles.clearLookupButton}
onPress={handleClearSeriesLookupPress}
>
<Icon name={icons.REMOVE} size={20} />
</Button>
</div>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<div className={styles.message}>
<div className={styles.helpText}>
{translate('AddNewSeriesError')}
</div>
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
</div>
) : null}
{!isFetching && !error && !!data.length ? (
<div className={styles.searchResults}>
{data.map((item) => {
return (
<AddNewSeriesSearchResult key={item.tvdbId} series={item} />
);
})}
</div>
) : null}
{!isFetching && !error && !data.length && term ? (
<div className={styles.message}>
<div className={styles.noResults}>
{translate('CouldNotFindResults', { term })}
</div>
<div>{translate('SearchByTvdbId')}</div>
<div>
<Link to="https://wiki.servarr.com/sonarr/faq#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
{translate('WhyCantIFindMyShow')}
</Link>
</div>
</div>
) : null}
{term ? null : (
<div className={styles.message}>
<div className={styles.helpText}>
{translate('AddNewSeriesHelpText')}
</div>
<div>{translate('SearchByTvdbId')}</div>
</div>
)}
{!term && !seriesCount ? (
<div className={styles.message}>
<div className={styles.noSeriesText}>
{translate('NoSeriesHaveBeenAdded')}
</div>
<div>
<Button to="/add/import" kind={kinds.PRIMARY}>
{translate('ImportExistingSeries')}
</Button>
</div>
</div>
) : null}
<div />
</PageContentBody>
</PageContent>
);
}
export default AddNewSeries;

View File

@@ -1,104 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearAddSeries, lookupSeries } from 'Store/Actions/addSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import parseUrl from 'Utilities/String/parseUrl';
import AddNewSeries from './AddNewSeries';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.series.items.length,
(state) => state.router.location,
(addSeries, existingSeriesCount, location) => {
const { params } = parseUrl(location.search);
return {
...addSeries,
term: params.term,
hasExistingSeries: existingSeriesCount > 0
};
}
);
}
const mapDispatchToProps = {
lookupSeries,
clearAddSeries,
fetchRootFolders
};
class AddNewSeriesConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._seriesLookupTimeout = null;
}
componentDidMount() {
this.props.fetchRootFolders();
}
componentWillUnmount() {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
this.props.clearAddSeries();
}
//
// Listeners
onSeriesLookupChange = (term) => {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
if (term.trim() === '') {
this.props.clearAddSeries();
} else {
this._seriesLookupTimeout = setTimeout(() => {
this.props.lookupSeries({ term });
}, 300);
}
};
onClearSeriesLookup = () => {
this.props.clearAddSeries();
};
//
// Render
render() {
const {
term,
...otherProps
} = this.props;
return (
<AddNewSeries
term={term}
{...otherProps}
onSeriesLookupChange={this.onSeriesLookupChange}
onClearSeriesLookup={this.onClearSeriesLookup}
/>
);
}
}
AddNewSeriesConnector.propTypes = {
term: PropTypes.string,
lookupSeries: PropTypes.func.isRequired,
clearAddSeries: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector);

View File

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

View File

@@ -0,0 +1,23 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddNewSeriesModalContent, {
AddNewSeriesModalContentProps,
} from './AddNewSeriesModalContent';
interface AddNewSeriesModalProps extends AddNewSeriesModalContentProps {
isOpen: boolean;
}
function AddNewSeriesModal({
isOpen,
onModalClose,
...otherProps
}: AddNewSeriesModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<AddNewSeriesModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default AddNewSeriesModal;

View File

@@ -1,300 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import SeriesPoster from 'Series/SeriesPoster';
import * as seriesTypes from 'Utilities/Series/seriesTypes';
import translate from 'Utilities/String/translate';
import styles from './AddNewSeriesModalContent.css';
class AddNewSeriesModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
seriesType: props.initialSeriesType === seriesTypes.STANDARD ?
props.seriesType.value :
props.initialSeriesType
};
}
componentDidUpdate(prevProps) {
if (this.props.seriesType.value !== prevProps.seriesType.value) {
this.setState({ seriesType: this.props.seriesType.value });
}
}
//
// Listeners
onQualityProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
};
onAddSeriesPress = () => {
const {
seriesType
} = this.state;
this.props.onAddSeriesPress(
seriesType
);
};
//
// Render
render() {
const {
title,
year,
overview,
images,
isAdding,
rootFolderPath,
monitor,
qualityProfileId,
seriesType,
seasonFolder,
searchForMissingEpisodes,
searchForCutoffUnmetEpisodes,
folder,
tags,
isSmallScreen,
isWindows,
onModalClose,
onInputChange,
...otherProps
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{title}
{
!title.contains(year) && !!year &&
<span className={styles.year}>({year})</span>
}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
isSmallScreen ?
null :
<div className={styles.poster}>
<SeriesPoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
{
overview ?
<div className={styles.overview}>
{overview}
</div> :
null
}
<Form {...otherProps}>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
valueOptions={{
seriesFolder: folder,
isWindows
}}
selectedValueOptions={{
seriesFolder: folder,
isWindows
}}
helpText={translate('AddNewSeriesRootFolderHelpText', { folder })}
onChange={onInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('Monitor')}
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title={translate('MonitoringOptions')}
body={<SeriesMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
onChange={onInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={this.onQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('SeriesType')}
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title={translate('SeriesTypes')}
body={<SeriesTypePopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
onChange={onInputChange}
{...seriesType}
value={this.state.seriesType}
helpText={translate('SeriesTypesHelpText')}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SeasonFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="seasonFolder"
onChange={onInputChange}
{...seasonFolder}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div>
<label className={styles.searchLabelContainer}>
<span className={styles.searchLabel}>
{translate('AddNewSeriesSearchForMissingEpisodes')}
</span>
<CheckInput
containerClassName={styles.searchInputContainer}
className={styles.searchInput}
name="searchForMissingEpisodes"
onChange={onInputChange}
{...searchForMissingEpisodes}
/>
</label>
<label className={styles.searchLabelContainer}>
<span className={styles.searchLabel}>
{translate('AddNewSeriesSearchForCutoffUnmetEpisodes')}
</span>
<CheckInput
containerClassName={styles.searchInputContainer}
className={styles.searchInput}
name="searchForCutoffUnmetEpisodes"
onChange={onInputChange}
{...searchForCutoffUnmetEpisodes}
/>
</label>
</div>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={this.onAddSeriesPress}
>
{translate('AddSeriesWithTitle', { title })}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
AddNewSeriesModalContent.propTypes = {
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
overview: PropTypes.string,
initialSeriesType: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
seriesType: PropTypes.object.isRequired,
seasonFolder: PropTypes.object.isRequired,
searchForMissingEpisodes: PropTypes.object.isRequired,
searchForCutoffUnmetEpisodes: PropTypes.object.isRequired,
folder: PropTypes.string.isRequired,
tags: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
isWindows: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired,
onAddSeriesPress: PropTypes.func.isRequired
};
export default AddNewSeriesModalContent;

View File

@@ -0,0 +1,296 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
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 CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesType } from 'Series/Series';
import SeriesPoster from 'Series/SeriesPoster';
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 {
series: AddSeries;
initialSeriesType: SeriesType;
onModalClose: () => void;
}
function AddNewSeriesModalContent({
series,
initialSeriesType,
onModalClose,
}: AddNewSeriesModalContentProps) {
const { title, year, overview, images, folder } = series;
const options = useAddSeriesOptions();
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isWindows = useIsWindows();
const { isAdding, addError, addSeries } = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(options, {}, addError);
}, [options, addError]);
const [seriesType, setSeriesType] = useState<SeriesType>(
initialSeriesType === 'standard'
? settings.seriesType.value
: initialSeriesType
);
const {
monitor,
qualityProfileId,
rootFolderPath,
searchForCutoffUnmetEpisodes,
searchForMissingEpisodes,
seasonFolder,
seriesType: seriesTypeSetting,
tags,
} = settings;
const handleInputChange = useCallback(
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
setAddSeriesOption(name as keyof AddSeriesOptions, value);
},
[]
);
const handleQualityProfileIdChange = useCallback(
({ value }: InputChanged<string | number>) => {
setAddSeriesOption('qualityProfileId', value as number);
},
[]
);
const handleAddSeriesPress = useCallback(() => {
addSeries({
...series,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value,
});
}, [
series,
seriesType,
rootFolderPath,
monitor,
qualityProfileId,
seasonFolder,
searchForMissingEpisodes,
searchForCutoffUnmetEpisodes,
tags,
addSeries,
]);
useEffect(() => {
setSeriesType(seriesTypeSetting.value);
}, [seriesTypeSetting]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{title}
{!title.includes(String(year)) && year ? (
<span className={styles.year}>({year})</span>
) : null}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{isSmallScreen ? null : (
<div className={styles.poster}>
<SeriesPoster
className={styles.poster}
images={images}
size={250}
/>
</div>
)}
<div className={styles.info}>
{overview ? (
<div className={styles.overview}>{overview}</div>
) : null}
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
valueOptions={{
seriesFolder: folder,
isWindows,
}}
selectedValueOptions={{
seriesFolder: folder,
isWindows,
}}
helpText={translate('AddNewSeriesRootFolderHelpText', {
folder,
})}
onChange={handleInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('Monitor')}
<Popover
anchor={
<Icon className={styles.labelIcon} name={icons.INFO} />
}
title={translate('MonitoringOptions')}
body={<SeriesMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
onChange={handleInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={handleQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('SeriesType')}
<Popover
anchor={
<Icon className={styles.labelIcon} name={icons.INFO} />
}
title={translate('SeriesTypes')}
body={<SeriesTypePopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
onChange={handleInputChange}
{...seriesTypeSetting}
value={seriesType}
helpText={translate('SeriesTypesHelpText')}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SeasonFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="seasonFolder"
onChange={handleInputChange}
{...seasonFolder}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={handleInputChange}
{...tags}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div>
<label className={styles.searchLabelContainer}>
<span className={styles.searchLabel}>
{translate('AddNewSeriesSearchForMissingEpisodes')}
</span>
<CheckInput
containerClassName={styles.searchInputContainer}
className={styles.searchInput}
name="searchForMissingEpisodes"
onChange={handleInputChange}
{...searchForMissingEpisodes}
/>
</label>
<label className={styles.searchLabelContainer}>
<span className={styles.searchLabel}>
{translate('AddNewSeriesSearchForCutoffUnmetEpisodes')}
</span>
<CheckInput
containerClassName={styles.searchInputContainer}
className={styles.searchInput}
name="searchForCutoffUnmetEpisodes"
onChange={handleInputChange}
{...searchForCutoffUnmetEpisodes}
/>
</label>
</div>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={handleAddSeriesPress}
>
{translate('AddSeriesWithTitle', { title })}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
export default AddNewSeriesModalContent;

View File

@@ -1,110 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import AddNewSeriesModalContent from './AddNewSeriesModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
createDimensionsSelector(),
createSystemStatusSelector(),
(addSeriesState, dimensions, systemStatus) => {
const {
isAdding,
addError,
defaults
} = addSeriesState;
const {
settings,
validationErrors,
validationWarnings
} = selectSettings(defaults, {}, addError);
return {
isAdding,
addError,
isSmallScreen: dimensions.isSmallScreen,
validationErrors,
validationWarnings,
isWindows: systemStatus.isWindows,
...settings
};
}
);
}
const mapDispatchToProps = {
setAddSeriesDefault,
addSeries
};
class AddNewSeriesModalContentConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setAddSeriesDefault({ [name]: value });
};
onAddSeriesPress = (seriesType) => {
const {
tvdbId,
rootFolderPath,
monitor,
qualityProfileId,
seasonFolder,
searchForMissingEpisodes,
searchForCutoffUnmetEpisodes,
tags
} = this.props;
this.props.addSeries({
tvdbId,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value
});
};
//
// Render
render() {
return (
<AddNewSeriesModalContent
{...this.props}
onInputChange={this.onInputChange}
onAddSeriesPress={this.onAddSeriesPress}
/>
);
}
}
AddNewSeriesModalContentConnector.propTypes = {
tvdbId: PropTypes.number.isRequired,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
seriesType: PropTypes.object.isRequired,
seasonFolder: PropTypes.object.isRequired,
searchForMissingEpisodes: PropTypes.object.isRequired,
searchForCutoffUnmetEpisodes: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
onModalClose: PropTypes.func.isRequired,
setAddSeriesDefault: PropTypes.func.isRequired,
addSeries: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesModalContentConnector);

View File

@@ -1,276 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import MetadataAttribution from 'Components/MetadataAttribution';
import { icons, kinds, sizes } from 'Helpers/Props';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
class AddNewSeriesSearchResult extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isNewAddSeriesModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (!prevProps.isExistingSeries && this.props.isExistingSeries) {
this.onAddSeriesModalClose();
}
}
//
// Listeners
onPress = () => {
this.setState({ isNewAddSeriesModalOpen: true });
};
onAddSeriesModalClose = () => {
this.setState({ isNewAddSeriesModalOpen: false });
};
onTVDBLinkPress = (event) => {
event.stopPropagation();
};
//
// Render
render() {
const {
tvdbId,
title,
titleSlug,
year,
network,
originalLanguage,
genres,
status,
overview,
statistics,
ratings,
folder,
seriesType,
images,
isExistingSeries,
isSmallScreen
} = this.props;
const seasonCount = statistics.seasonCount;
const {
isNewAddSeriesModalOpen
} = this.state;
const linkProps = isExistingSeries ? { to: `/series/${titleSlug}` } : { onPress: this.onPress };
let seasons = translate('OneSeason');
if (seasonCount > 1) {
seasons = translate('CountSeasons', { count: seasonCount });
}
return (
<div className={styles.searchResult}>
<Link
className={styles.underlay}
{...linkProps}
/>
<div className={styles.overlay}>
{
isSmallScreen ?
null :
<SeriesPoster
className={styles.poster}
images={images}
size={250}
overflow={true}
lazy={false}
/>
}
<div className={styles.content}>
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.title}>
{title}
{
!title.contains(year) && year ?
<span className={styles.year}>
({year})
</span> :
null
}
</div>
</div>
<div className={styles.icons}>
{
isExistingSeries ?
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={36}
title={translate('AlreadyInYourLibrary')}
/> :
null
}
<Link
className={styles.tvdbLink}
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
onPress={this.onTVDBLinkPress}
>
<Icon
className={styles.tvdbLinkIcon}
name={icons.EXTERNAL_LINK}
size={28}
/>
</Link>
</div>
</div>
<div>
<Label size={sizes.LARGE}>
<HeartRating
rating={ratings.value}
votes={ratings.votes}
iconSize={13}
/>
</Label>
{
originalLanguage?.name ?
<Label size={sizes.LARGE}>
<Icon
name={icons.LANGUAGE}
size={13}
/>
<span className={styles.originalLanguageName}>
{originalLanguage.name}
</span>
</Label> :
null
}
{
network ?
<Label size={sizes.LARGE}>
<Icon
name={icons.NETWORK}
size={13}
/>
<span className={styles.network}>
{network}
</span>
</Label> :
null
}
{
genres.length > 0 ?
<Label size={sizes.LARGE}>
<Icon
name={icons.GENRE}
size={13}
/>
<SeriesGenres className={styles.genres} genres={genres} />
</Label> :
null
}
{
seasonCount ?
<Label size={sizes.LARGE}>
{seasons}
</Label> :
null
}
{
status === 'ended' ?
<Label
kind={kinds.DANGER}
size={sizes.LARGE}
>
{translate('Ended')}
</Label> :
null
}
{
status === 'upcoming' ?
<Label
kind={kinds.INFO}
size={sizes.LARGE}
>
{translate('Upcoming')}
</Label> :
null
}
</div>
<div className={styles.overview}>
{overview}
</div>
<MetadataAttribution />
</div>
</div>
<AddNewSeriesModal
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
tvdbId={tvdbId}
title={title}
year={year}
overview={overview}
folder={folder}
initialSeriesType={seriesType}
images={images}
onModalClose={this.onAddSeriesModalClose}
/>
</div>
);
}
}
AddNewSeriesSearchResult.propTypes = {
tvdbId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
network: PropTypes.string,
originalLanguage: PropTypes.object,
genres: PropTypes.arrayOf(PropTypes.string),
status: PropTypes.string.isRequired,
overview: PropTypes.string,
statistics: PropTypes.object.isRequired,
ratings: PropTypes.object.isRequired,
folder: PropTypes.string.isRequired,
seriesType: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isExistingSeries: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired
};
AddNewSeriesSearchResult.defaultProps = {
genres: []
};
export default AddNewSeriesSearchResult;

View File

@@ -0,0 +1,182 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import MetadataAttribution from 'Components/MetadataAttribution';
import { icons, kinds, sizes } from 'Helpers/Props';
import { Statistics } from 'Series/Series';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
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;
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const { isSmallScreen } = useSelector(createDimensionsSelector());
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
const seasonCount = statistics.seasonCount;
const handlePress = useCallback(() => {
setIsNewAddSeriesModalOpen(true);
}, []);
const handleAddSeriesModalClose = useCallback(() => {
setIsNewAddSeriesModalOpen(false);
}, []);
const handleTvdbLinkPress = useCallback((event: React.SyntheticEvent) => {
event.stopPropagation();
}, []);
const linkProps = isExistingSeries
? { to: `/series/${titleSlug}` }
: { onPress: handlePress };
let seasons = translate('OneSeason');
if (seasonCount > 1) {
seasons = translate('CountSeasons', { count: seasonCount });
}
return (
<div className={styles.searchResult}>
<Link className={styles.underlay} {...linkProps} />
<div className={styles.overlay}>
{isSmallScreen ? null : (
<SeriesPoster
className={styles.poster}
images={images}
size={250}
overflow={true}
lazy={false}
/>
)}
<div className={styles.content}>
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.title}>
{title}
{!title.includes(String(year)) && year ? (
<span className={styles.year}>({year})</span>
) : null}
</div>
</div>
<div className={styles.icons}>
{isExistingSeries ? (
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={36}
title={translate('AlreadyInYourLibrary')}
/>
) : null}
<Link
className={styles.tvdbLink}
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
onPress={handleTvdbLinkPress}
>
<Icon
className={styles.tvdbLinkIcon}
name={icons.EXTERNAL_LINK}
size={28}
/>
</Link>
</div>
</div>
<div>
<Label size={sizes.LARGE}>
<HeartRating
rating={ratings.value}
votes={ratings.votes}
iconSize={13}
/>
</Label>
{originalLanguage?.name ? (
<Label size={sizes.LARGE}>
<Icon name={icons.LANGUAGE} size={13} />
<span className={styles.originalLanguageName}>
{originalLanguage.name}
</span>
</Label>
) : null}
{network ? (
<Label size={sizes.LARGE}>
<Icon name={icons.NETWORK} size={13} />
<span className={styles.network}>{network}</span>
</Label>
) : null}
{genres.length > 0 ? (
<Label size={sizes.LARGE}>
<Icon name={icons.GENRE} size={13} />
<SeriesGenres className={styles.genres} genres={genres} />
</Label>
) : null}
{seasonCount ? <Label size={sizes.LARGE}>{seasons}</Label> : null}
{status === 'ended' ? (
<Label kind={kinds.DANGER} size={sizes.LARGE}>
{translate('Ended')}
</Label>
) : null}
{status === 'upcoming' ? (
<Label kind={kinds.INFO} size={sizes.LARGE}>
{translate('Upcoming')}
</Label>
) : null}
</div>
<div className={styles.overview}>{overview}</div>
<MetadataAttribution />
</div>
</div>
<AddNewSeriesModal
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
series={series}
initialSeriesType={seriesType}
onModalClose={handleAddSeriesModalClose}
/>
</div>
);
}
export default AddNewSeriesSearchResult;

View File

@@ -1,20 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
function createMapStateToProps() {
return createSelector(
createExistingSeriesSelector(),
createDimensionsSelector(),
(isExistingSeries, dimensions) => {
return {
isExistingSeries,
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
export default connect(createMapStateToProps)(AddNewSeriesSearchResult);

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,179 +0,0 @@
import { reduce } from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import ImportSeriesFooterConnector from './ImportSeriesFooterConnector';
import ImportSeriesTableConnector from './ImportSeriesTableConnector';
class ImportSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.scrollerRef = React.createRef();
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {}
};
}
//
// Listeners
getSelectedIds = () => {
return reduce(
this.state.selectedState,
(result, value, id) => {
if (value) {
result.push(id);
}
return result;
},
[]
);
};
onSelectAllChange = ({ value }) => {
// Only select non-dupes
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
};
onRemoveSelectedStateItem = (id) => {
this.setState((state) => {
const selectedState = Object.assign({}, state.selectedState);
delete selectedState[id];
return {
...state,
selectedState
};
});
};
onInputChange = ({ name, value }) => {
this.props.onInputChange(this.getSelectedIds(), name, value);
};
onImportPress = () => {
this.props.onImportPress(this.getSelectedIds());
};
//
// Render
render() {
const {
rootFolderId,
path,
rootFoldersFetching,
rootFoldersPopulated,
rootFoldersError,
unmappedFolders
} = this.props;
const {
allSelected,
allUnselected,
selectedState
} = this.state;
return (
<PageContent title={translate('ImportSeries')}>
<PageContentBody ref={this.scrollerRef} >
{
rootFoldersFetching ? <LoadingIndicator /> : null
}
{
!rootFoldersFetching && !!rootFoldersError ?
<Alert kind={kinds.DANGER}>
{translate('RootFoldersLoadError')}
</Alert> :
null
}
{
!rootFoldersError &&
!rootFoldersFetching &&
rootFoldersPopulated &&
!unmappedFolders.length ?
<Alert kind={kinds.INFO}>
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
</Alert> :
null
}
{
!rootFoldersError &&
!rootFoldersFetching &&
rootFoldersPopulated &&
!!unmappedFolders.length &&
this.scrollerRef.current ?
<ImportSeriesTableConnector
rootFolderId={rootFolderId}
unmappedFolders={unmappedFolders}
allSelected={allSelected}
allUnselected={allUnselected}
selectedState={selectedState}
scroller={this.scrollerRef.current}
onSelectAllChange={this.onSelectAllChange}
onSelectedChange={this.onSelectedChange}
onRemoveSelectedStateItem={this.onRemoveSelectedStateItem}
/> :
null
}
</PageContentBody>
{
!rootFoldersError &&
!rootFoldersFetching &&
!!unmappedFolders.length ?
<ImportSeriesFooterConnector
selectedIds={this.getSelectedIds()}
onInputChange={this.onInputChange}
onImportPress={this.onImportPress}
/> :
null
}
</PageContent>
);
}
}
ImportSeries.propTypes = {
rootFolderId: PropTypes.number.isRequired,
path: PropTypes.string,
rootFoldersFetching: PropTypes.bool.isRequired,
rootFoldersPopulated: PropTypes.bool.isRequired,
rootFoldersError: PropTypes.object,
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
items: PropTypes.arrayOf(PropTypes.object),
onInputChange: PropTypes.func.isRequired,
onImportPress: PropTypes.func.isRequired
};
ImportSeries.defaultProps = {
unmappedFolders: []
};
export default ImportSeries;

View File

@@ -0,0 +1,127 @@
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';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { kinds } from 'Helpers/Props';
import { clearImportSeries } from 'Store/Actions/importSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import translate from 'Utilities/String/translate';
import ImportSeriesFooter from './ImportSeriesFooter';
import ImportSeriesTable from './ImportSeriesTable';
function ImportSeries() {
const dispatch = useDispatch();
const { rootFolderId: rootFolderIdString } = useParams<{
rootFolderId: string;
}>();
const rootFolderId = parseInt(rootFolderIdString);
const {
isFetching: rootFoldersFetching,
isPopulated: rootFoldersPopulated,
error: rootFoldersError,
items: rootFolders,
} = useSelector((state: AppState) => state.rootFolders);
const { path, unmappedFolders } = useMemo(() => {
const rootFolder = rootFolders.find((r) => r.id === rootFolderId);
return {
path: rootFolder?.path ?? '',
unmappedFolders:
rootFolder?.unmappedFolders.map((unmappedFolders) => {
return {
...unmappedFolders,
id: unmappedFolders.name,
};
}) ?? [],
};
}, [rootFolders, rootFolderId]);
const qualityProfiles = useSelector(
(state: AppState) => state.settings.qualityProfiles.items
);
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
const scrollerRef = useRef<HTMLDivElement>(null);
const items = useMemo(() => {
return unmappedFolders.map((unmappedFolder) => {
return {
...unmappedFolder,
id: unmappedFolder.name,
};
});
}, [unmappedFolders]);
useEffect(() => {
dispatch(fetchRootFolders({ id: rootFolderId, timeout: false }));
return () => {
dispatch(clearImportSeries());
};
}, [rootFolderId, dispatch]);
useEffect(() => {
if (
!defaultQualityProfileId ||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
) {
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
}
}, [defaultQualityProfileId, qualityProfiles, dispatch]);
return (
<SelectProvider items={items}>
<PageContent title={translate('ImportSeries')}>
<PageContentBody ref={scrollerRef}>
{rootFoldersFetching ? <LoadingIndicator /> : null}
{!rootFoldersFetching && !!rootFoldersError ? (
<Alert kind={kinds.DANGER}>
{translate('RootFoldersLoadError')}
</Alert>
) : null}
{!rootFoldersError &&
!rootFoldersFetching &&
rootFoldersPopulated &&
!unmappedFolders.length ? (
<Alert kind={kinds.INFO}>
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
</Alert>
) : null}
{!rootFoldersError &&
!rootFoldersFetching &&
rootFoldersPopulated &&
!!unmappedFolders.length &&
scrollerRef.current ? (
<ImportSeriesTable
unmappedFolders={unmappedFolders}
scrollerRef={scrollerRef}
/>
) : null}
</PageContentBody>
{!rootFoldersError &&
!rootFoldersFetching &&
!!unmappedFolders.length ? (
<ImportSeriesFooter />
) : null}
</PageContent>
</SelectProvider>
);
}
export default ImportSeries;

View File

@@ -1,153 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import { clearImportSeries, importSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import ImportSeries from './ImportSeries';
function createMapStateToProps() {
return createSelector(
(state, { match }) => match,
(state) => state.rootFolders,
(state) => state.addSeries,
(state) => state.importSeries,
(state) => state.settings.qualityProfiles,
(
match,
rootFolders,
addSeries,
importSeriesState,
qualityProfiles
) => {
const {
isFetching: rootFoldersFetching,
isPopulated: rootFoldersPopulated,
error: rootFoldersError,
items
} = rootFolders;
const rootFolderId = parseInt(match.params.rootFolderId);
const result = {
rootFolderId,
rootFoldersFetching,
rootFoldersPopulated,
rootFoldersError,
qualityProfiles: qualityProfiles.items,
defaultQualityProfileId: addSeries.defaults.qualityProfileId
};
if (items.length) {
const rootFolder = _.find(items, { id: rootFolderId });
return {
...result,
...rootFolder,
items: importSeriesState.items
};
}
return result;
}
);
}
const mapDispatchToProps = {
dispatchSetImportSeriesValue: setImportSeriesValue,
dispatchImportSeries: importSeries,
dispatchClearImportSeries: clearImportSeries,
dispatchFetchRootFolders: fetchRootFolders,
dispatchSetAddSeriesDefault: setAddSeriesDefault
};
class ImportSeriesConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
rootFolderId,
qualityProfiles,
defaultQualityProfileId,
dispatchFetchRootFolders,
dispatchSetAddSeriesDefault
} = this.props;
dispatchFetchRootFolders({ id: rootFolderId, timeout: false });
let setDefaults = false;
const setDefaultPayload = {};
if (
!defaultQualityProfileId ||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
) {
setDefaults = true;
setDefaultPayload.qualityProfileId = qualityProfiles[0].id;
}
if (setDefaults) {
dispatchSetAddSeriesDefault(setDefaultPayload);
}
}
componentWillUnmount() {
this.props.dispatchClearImportSeries();
}
//
// Listeners
onInputChange = (ids, name, value) => {
this.props.dispatchSetAddSeriesDefault({ [name]: value });
ids.forEach((id) => {
this.props.dispatchSetImportSeriesValue({
id,
[name]: value
});
});
};
onImportPress = (ids) => {
this.props.dispatchImportSeries({ ids });
};
//
// Render
render() {
return (
<ImportSeries
{...this.props}
onInputChange={this.onInputChange}
onImportPress={this.onImportPress}
/>
);
}
}
const routeMatchShape = createRouteMatchShape({
rootFolderId: PropTypes.string.isRequired
});
ImportSeriesConnector.propTypes = {
match: routeMatchShape.isRequired,
rootFolderId: PropTypes.number.isRequired,
rootFoldersFetching: PropTypes.bool.isRequired,
rootFoldersPopulated: PropTypes.bool.isRequired,
qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
defaultQualityProfileId: PropTypes.number.isRequired,
dispatchSetImportSeriesValue: PropTypes.func.isRequired,
dispatchImportSeries: PropTypes.func.isRequired,
dispatchClearImportSeries: PropTypes.func.isRequired,
dispatchFetchRootFolders: PropTypes.func.isRequired,
dispatchSetAddSeriesDefault: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector);

View File

@@ -1,300 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import FormInputGroup from 'Components/Form/FormInputGroup';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContentFooter from 'Components/Page/PageContentFooter';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ImportSeriesFooter.css';
const MIXED = 'mixed';
class ImportSeriesFooter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
defaultMonitor,
defaultQualityProfileId,
defaultSeasonFolder,
defaultSeriesType
} = props;
this.state = {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder
};
}
componentDidUpdate(prevProps, prevState) {
const {
defaultMonitor,
defaultQualityProfileId,
defaultSeriesType,
defaultSeasonFolder,
isMonitorMixed,
isQualityProfileIdMixed,
isSeriesTypeMixed,
isSeasonFolderMixed
} = this.props;
const {
monitor,
qualityProfileId,
seriesType,
seasonFolder
} = this.state;
const newState = {};
if (isMonitorMixed && monitor !== MIXED) {
newState.monitor = MIXED;
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
newState.monitor = defaultMonitor;
}
if (isQualityProfileIdMixed && qualityProfileId !== MIXED) {
newState.qualityProfileId = MIXED;
} else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) {
newState.qualityProfileId = defaultQualityProfileId;
}
if (isSeriesTypeMixed && seriesType !== MIXED) {
newState.seriesType = MIXED;
} else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
newState.seriesType = defaultSeriesType;
}
if (isSeasonFolderMixed && seasonFolder != null) {
newState.seasonFolder = null;
} else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
newState.seasonFolder = defaultSeasonFolder;
}
if (!_.isEmpty(newState)) {
this.setState(newState);
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
this.props.onInputChange({ name, value });
};
//
// Render
render() {
const {
selectedCount,
isImporting,
isLookingUpSeries,
isMonitorMixed,
isQualityProfileIdMixed,
isSeriesTypeMixed,
hasUnsearchedItems,
importError,
onImportPress,
onLookupPress,
onCancelLookupPress
} = this.props;
const {
monitor,
qualityProfileId,
seriesType,
seasonFolder
} = this.state;
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<div className={styles.label}>
{translate('Monitor')}
</div>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
value={monitor}
isDisabled={!selectedCount}
includeMixed={isMonitorMixed}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
{translate('QualityProfile')}
</div>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
isDisabled={!selectedCount}
includeMixed={isQualityProfileIdMixed}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
{translate('SeriesType')}
</div>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
value={seriesType}
isDisabled={!selectedCount}
includeMixed={isSeriesTypeMixed}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
{translate('SeasonFolder')}
</div>
<CheckInput
name="seasonFolder"
value={seasonFolder}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div>
<div className={styles.label}>
&nbsp;
</div>
<div className={styles.importButtonContainer}>
<SpinnerButton
className={styles.importButton}
kind={kinds.PRIMARY}
isSpinning={isImporting}
isDisabled={!selectedCount || isLookingUpSeries}
onPress={onImportPress}
>
{translate('ImportCountSeries', { selectedCount })}
</SpinnerButton>
{
isLookingUpSeries ?
<Button
className={styles.loadingButton}
kind={kinds.WARNING}
onPress={onCancelLookupPress}
>
{translate('CancelProcessing')}
</Button> :
null
}
{
hasUnsearchedItems ?
<Button
className={styles.loadingButton}
kind={kinds.SUCCESS}
onPress={onLookupPress}
>
{translate('StartProcessing')}
</Button> :
null
}
{
isLookingUpSeries ?
<LoadingIndicator
className={styles.loading}
size={24}
/> :
null
}
{
isLookingUpSeries ?
translate('ProcessingFolders') :
null
}
{
importError ?
<Popover
anchor={
<Icon
className={styles.importError}
name={icons.WARNING}
kind={kinds.WARNING}
/>
}
title={translate('ImportErrors')}
body={
<ul>
{
Array.isArray(importError.responseJSON) ?
importError.responseJSON.map((error, index) => {
return (
<li key={index}>
{error.errorMessage}
</li>
);
}) :
<li>
{
JSON.stringify(importError.responseJSON)
}
</li>
}
</ul>
}
position={tooltipPositions.RIGHT}
/> :
null
}
</div>
</div>
</PageContentFooter>
);
}
}
ImportSeriesFooter.propTypes = {
selectedCount: PropTypes.number.isRequired,
isImporting: PropTypes.bool.isRequired,
isLookingUpSeries: PropTypes.bool.isRequired,
defaultMonitor: PropTypes.string.isRequired,
defaultQualityProfileId: PropTypes.number,
defaultSeriesType: PropTypes.string.isRequired,
defaultSeasonFolder: PropTypes.bool.isRequired,
isMonitorMixed: PropTypes.bool.isRequired,
isQualityProfileIdMixed: PropTypes.bool.isRequired,
isSeriesTypeMixed: PropTypes.bool.isRequired,
isSeasonFolderMixed: PropTypes.bool.isRequired,
hasUnsearchedItems: PropTypes.bool.isRequired,
importError: PropTypes.object,
onInputChange: PropTypes.func.isRequired,
onImportPress: PropTypes.func.isRequired,
onLookupPress: PropTypes.func.isRequired,
onCancelLookupPress: PropTypes.func.isRequired
};
export default ImportSeriesFooter;

View File

@@ -0,0 +1,314 @@
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';
import FormInputGroup from 'Components/Form/FormInputGroup';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 {
cancelLookupSeries,
importSeries,
lookupUnsearchedSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import styles from './ImportSeriesFooter.css';
type MixedType = 'mixed';
function ImportSeriesFooter() {
const dispatch = useDispatch();
const {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder,
} = useAddSeriesOptions();
const { isLookingUpSeries, isImporting, items, importError } = useSelector(
(state: AppState) => state.importSeries
);
const [monitor, setMonitor] = useState<SeriesMonitor | MixedType>(
defaultMonitor
);
const [qualityProfileId, setQualityProfileId] = useState<number | MixedType>(
defaultQualityProfileId
);
const [seriesType, setSeriesType] = useState<SeriesType | MixedType>(
defaultSeriesType
);
const [seasonFolder, setSeasonFolder] = useState<boolean | MixedType>(
defaultSeasonFolder
);
const [selectState] = useSelect();
const selectedIds = useMemo(() => {
return getSelectedIds(selectState.selectedState, (id) => id);
}, [selectState.selectedState]);
const {
hasUnsearchedItems,
isMonitorMixed,
isQualityProfileIdMixed,
isSeriesTypeMixed,
isSeasonFolderMixed,
} = useMemo(() => {
let isMonitorMixed = false;
let isQualityProfileIdMixed = false;
let isSeriesTypeMixed = false;
let isSeasonFolderMixed = false;
let hasUnsearchedItems = false;
items.forEach((item) => {
if (item.monitor !== defaultMonitor) {
isMonitorMixed = true;
}
if (item.qualityProfileId !== defaultQualityProfileId) {
isQualityProfileIdMixed = true;
}
if (item.seriesType !== defaultSeriesType) {
isSeriesTypeMixed = true;
}
if (item.seasonFolder !== defaultSeasonFolder) {
isSeasonFolderMixed = true;
}
if (!item.isPopulated) {
hasUnsearchedItems = true;
}
});
return {
hasUnsearchedItems: !isLookingUpSeries && hasUnsearchedItems,
isMonitorMixed,
isQualityProfileIdMixed,
isSeriesTypeMixed,
isSeasonFolderMixed,
};
}, [
defaultMonitor,
defaultQualityProfileId,
defaultSeasonFolder,
defaultSeriesType,
items,
isLookingUpSeries,
]);
const handleInputChange = useCallback(
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
if (name === 'monitor') {
setMonitor(value as SeriesMonitor);
} else if (name === 'qualityProfileId') {
setQualityProfileId(value as number);
} else if (name === 'seriesType') {
setSeriesType(value as SeriesType);
} else if (name === 'seasonFolder') {
setSeasonFolder(value as boolean);
}
setAddSeriesOption(name as keyof AddSeriesOptions, value);
selectedIds.forEach((id) => {
dispatch(
// @ts-expect-error - actions are not typed
setImportSeriesValue({
id,
[name]: value,
})
);
});
},
[selectedIds, dispatch]
);
const handleLookupPress = useCallback(() => {
dispatch(lookupUnsearchedSeries());
}, [dispatch]);
const handleCancelLookupPress = useCallback(() => {
dispatch(cancelLookupSeries());
}, [dispatch]);
const handleImportPress = useCallback(() => {
dispatch(importSeries({ ids: selectedIds }));
}, [selectedIds, dispatch]);
useEffect(() => {
if (isMonitorMixed && monitor !== 'mixed') {
setMonitor('mixed');
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
setMonitor(defaultMonitor);
}
}, [defaultMonitor, isMonitorMixed, monitor]);
useEffect(() => {
if (isQualityProfileIdMixed && qualityProfileId !== 'mixed') {
setQualityProfileId('mixed');
} else if (
!isQualityProfileIdMixed &&
qualityProfileId !== defaultQualityProfileId
) {
setQualityProfileId(defaultQualityProfileId);
}
}, [defaultQualityProfileId, isQualityProfileIdMixed, qualityProfileId]);
useEffect(() => {
if (isSeriesTypeMixed && seriesType !== 'mixed') {
setSeriesType('mixed');
} else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
setSeriesType(defaultSeriesType);
}
}, [defaultSeriesType, isSeriesTypeMixed, seriesType]);
useEffect(() => {
if (isSeasonFolderMixed && seasonFolder !== 'mixed') {
setSeasonFolder('mixed');
} else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
setSeasonFolder(defaultSeasonFolder);
}
}, [defaultSeasonFolder, isSeasonFolderMixed, seasonFolder]);
const selectedCount = selectedIds.length;
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<div className={styles.label}>{translate('Monitor')}</div>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
value={monitor}
isDisabled={!selectedCount}
includeMixed={isMonitorMixed}
onChange={handleInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>{translate('QualityProfile')}</div>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
isDisabled={!selectedCount}
includeMixed={isQualityProfileIdMixed}
onChange={handleInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>{translate('SeriesType')}</div>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
value={seriesType}
isDisabled={!selectedCount}
includeMixed={isSeriesTypeMixed}
onChange={handleInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>{translate('SeasonFolder')}</div>
<CheckInput
name="seasonFolder"
value={seasonFolder}
isDisabled={!selectedCount}
onChange={handleInputChange}
/>
</div>
<div>
<div className={styles.label}>&nbsp;</div>
<div className={styles.importButtonContainer}>
<SpinnerButton
className={styles.importButton}
kind={kinds.PRIMARY}
isSpinning={isImporting}
isDisabled={!selectedCount || isLookingUpSeries}
onPress={handleImportPress}
>
{translate('ImportCountSeries', { selectedCount })}
</SpinnerButton>
{isLookingUpSeries ? (
<Button
className={styles.loadingButton}
kind={kinds.WARNING}
onPress={handleCancelLookupPress}
>
{translate('CancelProcessing')}
</Button>
) : null}
{hasUnsearchedItems ? (
<Button
className={styles.loadingButton}
kind={kinds.SUCCESS}
onPress={handleLookupPress}
>
{translate('StartProcessing')}
</Button>
) : null}
{isLookingUpSeries ? (
<LoadingIndicator className={styles.loading} size={24} />
) : null}
{isLookingUpSeries ? translate('ProcessingFolders') : null}
{importError ? (
<Popover
anchor={
<Icon
className={styles.importError}
name={icons.WARNING}
kind={kinds.WARNING}
/>
}
title={translate('ImportErrors')}
body={
<ul>
{Array.isArray(importError.responseJSON) ? (
importError.responseJSON.map((error, index) => {
return <li key={index}>{error.errorMessage}</li>;
})
) : (
<li>{JSON.stringify(importError.responseJSON)}</li>
)}
</ul>
}
position={tooltipPositions.RIGHT}
/>
) : null}
</div>
</div>
</PageContentFooter>
);
}
export default ImportSeriesFooter;

View File

@@ -1,63 +0,0 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { cancelLookupSeries, lookupUnsearchedSeries } from 'Store/Actions/importSeriesActions';
import ImportSeriesFooter from './ImportSeriesFooter';
function isMixed(items, selectedIds, defaultValue, key) {
return _.some(items, (series) => {
return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue;
});
}
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.importSeries,
(state, { selectedIds }) => selectedIds,
(addSeries, importSeries, selectedIds) => {
const {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder
} = addSeries.defaults;
const {
isLookingUpSeries,
isImporting,
items,
importError
} = importSeries;
const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
const isSeriesTypeMixed = isMixed(items, selectedIds, defaultSeriesType, 'seriesType');
const isSeasonFolderMixed = isMixed(items, selectedIds, defaultSeasonFolder, 'seasonFolder');
const hasUnsearchedItems = !isLookingUpSeries && items.some((item) => !item.isPopulated);
return {
selectedCount: selectedIds.length,
isLookingUpSeries,
isImporting,
defaultMonitor,
defaultQualityProfileId,
defaultSeriesType,
defaultSeasonFolder,
isMonitorMixed,
isQualityProfileIdMixed,
isSeriesTypeMixed,
isSeasonFolderMixed,
importError,
hasUnsearchedItems
};
}
);
}
const mapDispatchToProps = {
onLookupPress: lookupUnsearchedSeries,
onCancelLookupPress: cancelLookupSeries
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesFooter);

View File

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
@@ -8,16 +7,21 @@ import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import Popover from 'Components/Tooltip/Popover';
import { icons, tooltipPositions } from 'Helpers/Props';
import { CheckInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './ImportSeriesHeader.css';
function ImportSeriesHeader(props) {
const {
allSelected,
allUnselected,
onSelectAllChange
} = props;
interface ImportSeriesHeaderProps {
allSelected: boolean;
allUnselected: boolean;
onSelectAllChange: (change: CheckInputChanged) => void;
}
function ImportSeriesHeader({
allSelected,
allUnselected,
onSelectAllChange,
}: ImportSeriesHeaderProps) {
return (
<VirtualTableHeader>
<VirtualTableSelectAllHeaderCell
@@ -26,26 +30,15 @@ function ImportSeriesHeader(props) {
onSelectAllChange={onSelectAllChange}
/>
<VirtualTableHeaderCell
className={styles.folder}
name="folder"
>
<VirtualTableHeaderCell className={styles.folder} name="folder">
{translate('Folder')}
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.monitor}
name="monitor"
>
<VirtualTableHeaderCell className={styles.monitor} name="monitor">
{translate('Monitor')}
<Popover
anchor={
<Icon
className={styles.detailsIcon}
name={icons.INFO}
/>
}
anchor={<Icon className={styles.detailsIcon} name={icons.INFO} />}
title={translate('MonitoringOptions')}
body={<SeriesMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
@@ -59,19 +52,11 @@ function ImportSeriesHeader(props) {
{translate('QualityProfile')}
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.seriesType}
name="seriesType"
>
<VirtualTableHeaderCell className={styles.seriesType} name="seriesType">
{translate('SeriesType')}
<Popover
anchor={
<Icon
className={styles.detailsIcon}
name={icons.INFO}
/>
}
anchor={<Icon className={styles.detailsIcon} name={icons.INFO} />}
title={translate('SeriesType')}
body={<SeriesTypePopoverContent />}
position={tooltipPositions.RIGHT}
@@ -85,20 +70,11 @@ function ImportSeriesHeader(props) {
{translate('SeasonFolder')}
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.series}
name="series"
>
<VirtualTableHeaderCell className={styles.series} name="series">
{translate('Series')}
</VirtualTableHeaderCell>
</VirtualTableHeader>
);
}
ImportSeriesHeader.propTypes = {
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired
};
export default ImportSeriesHeader;

View File

@@ -1,105 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import FormInputGroup from 'Components/Form/FormInputGroup';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import { inputTypes } from 'Helpers/Props';
import ImportSeriesSelectSeriesConnector from './SelectSeries/ImportSeriesSelectSeriesConnector';
import styles from './ImportSeriesRow.css';
function ImportSeriesRow(props) {
const {
id,
relativePath,
monitor,
qualityProfileId,
seasonFolder,
seriesType,
selectedSeries,
isExistingSeries,
isSelected,
onSelectedChange,
onInputChange
} = props;
return (
<>
<VirtualTableSelectCell
inputClassName={styles.selectInput}
id={id}
isSelected={isSelected}
isDisabled={!selectedSeries || isExistingSeries}
onSelectedChange={onSelectedChange}
/>
<VirtualTableRowCell className={styles.folder}>
{relativePath}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.monitor}>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
value={monitor}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.qualityProfile}>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.seriesType}>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
value={seriesType}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.seasonFolder}>
<FormInputGroup
type={inputTypes.CHECK}
name="seasonFolder"
value={seasonFolder}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.series}>
<ImportSeriesSelectSeriesConnector
id={id}
isExistingSeries={isExistingSeries}
onInputChange={onInputChange}
/>
</VirtualTableRowCell>
</>
);
}
ImportSeriesRow.propTypes = {
id: PropTypes.string.isRequired,
relativePath: PropTypes.string.isRequired,
monitor: PropTypes.string.isRequired,
qualityProfileId: PropTypes.number.isRequired,
seriesType: PropTypes.string.isRequired,
seasonFolder: PropTypes.bool.isRequired,
selectedSeries: PropTypes.object,
isExistingSeries: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired
};
ImportSeriesRow.defaultsProps = {
items: []
};
export default ImportSeriesRow;

View File

@@ -0,0 +1,135 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
import FormInputGroup from 'Components/Form/FormInputGroup';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import { inputTypes } from 'Helpers/Props';
import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import { InputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries';
import styles from './ImportSeriesRow.css';
function createItemSelector(id: string) {
return createSelector(
(state: AppState) => state.importSeries.items,
(items) => {
return (
items.find((item) => {
return item.id === id;
}) || ({} as ImportSeries)
);
}
);
}
interface ImportSeriesRowProps {
id: string;
}
function ImportSeriesRow({ id }: ImportSeriesRowProps) {
const dispatch = useDispatch();
const {
relativePath,
monitor,
qualityProfileId,
seasonFolder,
seriesType,
selectedSeries,
} = useSelector(createItemSelector(id));
const isExistingSeries = useSelector(
createExistingSeriesSelector(selectedSeries?.tvdbId)
);
const [selectState, selectDispatch] = useSelect();
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
dispatch(
// @ts-expect-error - actions are not typed
setImportSeriesValue({
id,
[name]: value,
})
);
},
[id, dispatch]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey }: SelectStateInputProps) => {
selectDispatch({
type: 'toggleSelected',
id,
isSelected: value,
shiftKey,
});
},
[selectDispatch]
);
return (
<>
<VirtualTableSelectCell
inputClassName={styles.selectInput}
id={id}
isSelected={selectState.selectedState[id]}
isDisabled={!selectedSeries || isExistingSeries}
onSelectedChange={handleSelectedChange}
/>
<VirtualTableRowCell className={styles.folder}>
{relativePath}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.monitor}>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
value={monitor}
onChange={handleInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.qualityProfile}>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
onChange={handleInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.seriesType}>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
value={seriesType}
onChange={handleInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.seasonFolder}>
<FormInputGroup
type={inputTypes.CHECK}
name="seasonFolder"
value={seasonFolder}
onChange={handleInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.series}>
<ImportSeriesSelectSeries id={id} onInputChange={handleInputChange} />
</VirtualTableRowCell>
</>
);
}
export default ImportSeriesRow;

View File

@@ -1,89 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import ImportSeriesRow from './ImportSeriesRow';
function createImportSeriesItemSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.importSeries.items,
(id, items) => {
return _.find(items, { id }) || {};
}
);
}
function createMapStateToProps() {
return createSelector(
createImportSeriesItemSelector(),
createAllSeriesSelector(),
(item, series) => {
const selectedSeries = item && item.selectedSeries;
const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId });
return {
...item,
isExistingSeries
};
}
);
}
const mapDispatchToProps = {
setImportSeriesValue
};
class ImportSeriesRowConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setImportSeriesValue({
id: this.props.id,
[name]: value
});
};
//
// Render
render() {
// Don't show the row until we have the information we require for it.
const {
items,
monitor,
seriesType,
seasonFolder
} = this.props;
if (!items || !monitor || !seriesType || !seasonFolder == null) {
return null;
}
return (
<ImportSeriesRow
{...this.props}
onInputChange={this.onInputChange}
onSeriesSelect={this.onSeriesSelect}
/>
);
}
}
ImportSeriesRowConnector.propTypes = {
rootFolderId: PropTypes.number.isRequired,
id: PropTypes.string.isRequired,
monitor: PropTypes.string,
seriesType: PropTypes.string,
seasonFolder: PropTypes.bool,
items: PropTypes.arrayOf(PropTypes.object),
setImportSeriesValue: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector);

View File

@@ -0,0 +1,7 @@
.row {
transition: background-color 500ms;
&:hover {
background-color: var(--tableRowHoverBackgroundColor);
}
}

View File

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

View File

@@ -1,188 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import VirtualTable from 'Components/Table/VirtualTable';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import ImportSeriesHeader from './ImportSeriesHeader';
import ImportSeriesRowConnector from './ImportSeriesRowConnector';
class ImportSeriesTable extends Component {
//
// Lifecycle
componentDidMount() {
const {
unmappedFolders,
defaultMonitor,
defaultQualityProfileId,
defaultSeriesType,
defaultSeasonFolder,
onSeriesLookup,
onSetImportSeriesValue
} = this.props;
const values = {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder
};
unmappedFolders.forEach((unmappedFolder) => {
const id = unmappedFolder.name;
onSeriesLookup(id, unmappedFolder.path, unmappedFolder.relativePath);
onSetImportSeriesValue({
id,
...values
});
});
}
// This isn't great, but it's the most reliable way to ensure the items
// are checked off even if they aren't actually visible since the cells
// are virtualized.
componentDidUpdate(prevProps) {
const {
items,
selectedState,
onSelectedChange,
onRemoveSelectedStateItem
} = this.props;
prevProps.items.forEach((prevItem) => {
const {
id
} = prevItem;
const item = _.find(items, { id });
if (!item) {
onRemoveSelectedStateItem(id);
return;
}
const selectedSeries = item.selectedSeries;
const isSelected = selectedState[id];
const isExistingSeries = !!selectedSeries &&
_.some(prevProps.allSeries, { tvdbId: selectedSeries.tvdbId });
// Props doesn't have a selected series or
// the selected series is an existing series.
if ((!selectedSeries && prevItem.selectedSeries) || (isExistingSeries && !prevItem.selectedSeries)) {
onSelectedChange({ id, value: false });
return;
}
// State is selected, but a series isn't selected or
// the selected series is an existing series.
if (isSelected && (!selectedSeries || isExistingSeries)) {
onSelectedChange({ id, value: false });
return;
}
// A series is being selected that wasn't previously selected.
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
onSelectedChange({ id, value: true });
return;
}
});
}
//
// Control
rowRenderer = ({ key, rowIndex, style }) => {
const {
rootFolderId,
items,
selectedState,
onSelectedChange
} = this.props;
const item = items[rowIndex];
return (
<VirtualTableRow
key={key}
style={style}
>
<ImportSeriesRowConnector
key={item.id}
rootFolderId={rootFolderId}
isSelected={selectedState[item.id]}
onSelectedChange={onSelectedChange}
id={item.id}
/>
</VirtualTableRow>
);
};
//
// Render
render() {
const {
items,
allSelected,
allUnselected,
isSmallScreen,
scroller,
selectedState,
onSelectAllChange
} = this.props;
if (!items.length) {
return null;
}
return (
<VirtualTable
items={items}
scroller={scroller}
isSmallScreen={isSmallScreen}
rowHeight={52}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<ImportSeriesHeader
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
}
selectedState={selectedState}
/>
);
}
}
ImportSeriesTable.propTypes = {
rootFolderId: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
defaultMonitor: PropTypes.string.isRequired,
defaultQualityProfileId: PropTypes.number,
defaultSeriesType: PropTypes.string.isRequired,
defaultSeasonFolder: PropTypes.bool.isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
selectedState: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
allSeries: PropTypes.arrayOf(PropTypes.object),
scroller: PropTypes.instanceOf(Element).isRequired,
onSelectAllChange: PropTypes.func.isRequired,
onSelectedChange: PropTypes.func.isRequired,
onRemoveSelectedStateItem: PropTypes.func.isRequired,
onSeriesLookup: PropTypes.func.isRequired,
onSetImportSeriesValue: PropTypes.func.isRequired
};
export default ImportSeriesTable;

View File

@@ -0,0 +1,209 @@
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';
import VirtualTable from 'Components/Table/VirtualTable';
import usePrevious from 'Helpers/Hooks/usePrevious';
import {
queueLookupSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import { UnmappedFolder } from 'typings/RootFolder';
import ImportSeriesHeader from './ImportSeriesHeader';
import ImportSeriesRow from './ImportSeriesRow';
import styles from './ImportSeriesTable.css';
const ROW_HEIGHT = 52;
interface RowItemData {
items: ImportSeries[];
}
interface ImportSeriesTableProps {
unmappedFolders: UnmappedFolder[];
scrollerRef: RefObject<HTMLElement>;
}
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
const { items } = data;
if (index >= items.length) {
return null;
}
const item = items[index];
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
...style,
}}
className={styles.row}
>
<ImportSeriesRow key={item.id} id={item.id} />
</div>
);
}
function ImportSeriesTable({
unmappedFolders,
scrollerRef,
}: ImportSeriesTableProps) {
const dispatch = useDispatch();
const { monitor, qualityProfileId, seriesType, seasonFolder } =
useAddSeriesOptions();
const items = useSelector((state: AppState) => state.importSeries.items);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const allSeries = useSelector(createAllSeriesSelector());
const [selectState, selectDispatch] = useSelect();
const defaultValues = useRef({
monitor,
qualityProfileId,
seriesType,
seasonFolder,
});
const listRef = useRef<FixedSizeList<RowItemData>>(null);
const initialUnmappedFolders = useRef(unmappedFolders);
const previousItems = usePrevious(items);
const { allSelected, allUnselected, selectedState } = selectState;
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
selectDispatch({
type: value ? 'selectAll' : 'unselectAll',
});
},
[selectDispatch]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey }: SelectStateInputProps) => {
selectDispatch({
type: 'toggleSelected',
id,
isSelected: value,
shiftKey,
});
},
[selectDispatch]
);
const handleRemoveSelectedStateItem = useCallback(
(id: string) => {
selectDispatch({
type: 'removeItem',
id,
});
},
[selectDispatch]
);
useEffect(() => {
initialUnmappedFolders.current.forEach(({ name, path, relativePath }) => {
dispatch(
queueLookupSeries({
name,
path,
relativePath,
term: name,
})
);
dispatch(
// @ts-expect-error - actions are not typed
setImportSeriesValue({
id: name,
...defaultValues.current,
})
);
});
}, [dispatch]);
useEffect(() => {
previousItems?.forEach((prevItem) => {
const { id } = prevItem;
const item = items.find((i) => i.id === id);
if (!item) {
handleRemoveSelectedStateItem(id);
return;
}
const selectedSeries = item.selectedSeries;
const isSelected = selectedState[id];
const isExistingSeries =
!!selectedSeries &&
allSeries.some((s) => s.tvdbId === selectedSeries.tvdbId);
if (
(!selectedSeries && prevItem.selectedSeries) ||
(isExistingSeries && !prevItem.selectedSeries)
) {
handleSelectedChange({ id, value: false, shiftKey: false });
return;
}
if (isSelected && (!selectedSeries || isExistingSeries)) {
handleSelectedChange({ id, value: false, shiftKey: false });
return;
}
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
handleSelectedChange({ id, value: true, shiftKey: false });
return;
}
});
}, [
allSeries,
items,
previousItems,
selectedState,
handleRemoveSelectedStateItem,
handleSelectedChange,
]);
if (!items.length) {
return null;
}
return (
<VirtualTable
Header={
<ImportSeriesHeader
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={handleSelectAllChange}
/>
}
itemCount={items.length}
itemData={{
items,
}}
isSmallScreen={isSmallScreen}
listRef={listRef}
rowHeight={ROW_HEIGHT}
Row={Row}
scrollerRef={scrollerRef}
/>
);
}
export default ImportSeriesTable;

View File

@@ -1,44 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import ImportSeriesTable from './ImportSeriesTable';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.importSeries,
(state) => state.app.dimensions,
createAllSeriesSelector(),
(addSeries, importSeries, dimensions, allSeries) => {
return {
defaultMonitor: addSeries.defaults.monitor,
defaultQualityProfileId: addSeries.defaults.qualityProfileId,
defaultSeriesType: addSeries.defaults.seriesType,
defaultSeasonFolder: addSeries.defaults.seasonFolder,
items: importSeries.items,
isSmallScreen: dimensions.isSmallScreen,
allSeries
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSeriesLookup(name, path, relativePath) {
dispatch(queueLookupSeries({
name,
path,
relativePath,
term: name
}));
},
onSetImportSeriesValue(values) {
dispatch(setImportSeriesValue(values));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(ImportSeriesTable);

View File

@@ -1,29 +1,36 @@
import PropTypes from 'prop-types';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSearchResult.css';
function ImportSeriesSearchResult(props) {
const {
tvdbId,
title,
year,
network,
isExistingSeries,
onPress
} = props;
interface ImportSeriesSearchResultProps {
tvdbId: number;
title: string;
year: number;
network?: string;
onPress: (tvdbId: number) => void;
}
const onPressCallback = useCallback(() => onPress(tvdbId), [tvdbId, onPress]);
function ImportSeriesSearchResult({
tvdbId,
title,
year,
network,
onPress,
}: ImportSeriesSearchResultProps) {
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const handlePress = useCallback(() => {
onPress(tvdbId);
}, [tvdbId, onPress]);
return (
<div className={styles.container}>
<Link
className={styles.series}
onPress={onPressCallback}
>
<Link className={styles.series} onPress={handlePress}>
<ImportSeriesTitle
title={title}
year={year}
@@ -46,13 +53,4 @@ function ImportSeriesSearchResult(props) {
);
}
ImportSeriesSearchResult.propTypes = {
tvdbId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
network: PropTypes.string,
isExistingSeries: PropTypes.bool.isRequired,
onPress: PropTypes.func.isRequired
};
export default ImportSeriesSearchResult;

View File

@@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
function createMapStateToProps() {
return createSelector(
createExistingSeriesSelector(),
(isExistingSeries) => {
return {
isExistingSeries
};
}
);
}
export default connect(createMapStateToProps)(ImportSeriesSearchResult);

View File

@@ -1,303 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import FormInputButton from 'Components/Form/FormInputButton';
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 getUniqueElementId from 'Utilities/getUniqueElementId';
import translate from 'Utilities/String/translate';
import ImportSeriesSearchResultConnector from './ImportSeriesSearchResultConnector';
import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSelectSeries.css';
class ImportSeriesSelectSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._seriesLookupTimeout = null;
this._scheduleUpdate = null;
this._buttonId = getUniqueElementId();
this._contentId = getUniqueElementId();
this.state = {
term: props.id,
isOpen: false
};
}
componentDidUpdate() {
if (this._scheduleUpdate) {
this._scheduleUpdate();
}
}
//
// Control
_addListener() {
window.addEventListener('click', this.onWindowClick);
}
_removeListener() {
window.removeEventListener('click', this.onWindowClick);
}
//
// Listeners
onWindowClick = (event) => {
const button = document.getElementById(this._buttonId);
const content = document.getElementById(this._contentId);
if (!button || !content) {
return;
}
if (
!button.contains(event.target) &&
!content.contains(event.target) &&
this.state.isOpen
) {
this.setState({ isOpen: false });
this._removeListener();
}
};
onPress = () => {
if (this.state.isOpen) {
this._removeListener();
} else {
this._addListener();
}
this.setState({ isOpen: !this.state.isOpen });
};
onSearchInputChange = ({ value }) => {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
this.setState({ term: value }, () => {
this._seriesLookupTimeout = setTimeout(() => {
this.props.onSearchInputChange(value);
}, 200);
});
};
onRefreshPress = () => {
this.props.onSearchInputChange(this.state.term);
};
onSeriesSelect = (tvdbId) => {
this.setState({ isOpen: false });
this.props.onSeriesSelect(tvdbId);
};
//
// Render
render() {
const {
selectedSeries,
isExistingSeries,
isFetching,
isPopulated,
error,
items,
isQueued,
isLookingUpSeries
} = this.props;
const errorMessage = error &&
error.responseJSON &&
error.responseJSON.message;
return (
<Manager>
<Reference>
{({ ref }) => (
<div
ref={ref}
id={this._buttonId}
>
<Link
ref={ref}
className={styles.button}
component="div"
onPress={this.onPress}
>
{
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 ?
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
network={selectedSeries.network}
isExistingSeries={isExistingSeries}
/> :
null
}
{
isPopulated && !selectedSeries ?
<div className={styles.noMatches}>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('NoMatchFound')}
</div> :
null
}
{
!isFetching && !!error ?
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('SearchFailedError')}
</div> :
null
}
<div className={styles.dropdownArrowContainer}>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom"
modifiers={{
preventOverflow: {
boundariesElement: 'viewport'
}
}}
>
{({ ref, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={ref}
id={this._contentId}
className={styles.contentContainer}
style={style}
>
{
this.state.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={this.state.term}
onChange={this.onSearchInputChange}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={this.onRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
</div>
<div className={styles.results}>
{
items.map((item) => {
return (
<ImportSeriesSearchResultConnector
key={item.tvdbId}
tvdbId={item.tvdbId}
title={item.title}
year={item.year}
network={item.network}
onPress={this.onSeriesSelect}
/>
);
})
}
</div>
</div> :
null
}
</div>
);
}}
</Popper>
</Portal>
</Manager>
);
}
}
ImportSeriesSelectSeries.propTypes = {
id: PropTypes.string.isRequired,
selectedSeries: PropTypes.object,
isExistingSeries: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isQueued: PropTypes.bool.isRequired,
isLookingUpSeries: PropTypes.bool.isRequired,
onSearchInputChange: PropTypes.func.isRequired,
onSeriesSelect: PropTypes.func.isRequired
};
ImportSeriesSelectSeries.defaultProps = {
isFetching: true,
isPopulated: false,
items: [],
isQueued: true
};
export default ImportSeriesSelectSeries;

View File

@@ -0,0 +1,259 @@
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';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { icons, kinds } from 'Helpers/Props';
import {
queueLookupSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
import { InputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSelectSeries.css';
interface ImportSeriesSelectSeriesProps {
id: string;
onInputChange: (input: InputChanged) => void;
}
function ImportSeriesSelectSeries({
id,
onInputChange,
}: ImportSeriesSelectSeriesProps) {
const dispatch = useDispatch();
const isLookingUpSeries = useSelector(
(state: AppState) => state.importSeries.isLookingUpSeries
);
const {
error,
isFetching = true,
isPopulated = false,
items = [],
isQueued = true,
selectedSeries,
isExistingSeries,
term: itemTerm,
// @ts-expect-error - ignoring this for now
} = useSelector(createImportSeriesItemSelector(id, { id }));
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();
const [term, setTerm] = useState('');
const [isOpen, setIsOpen] = useState(false);
const errorMessage = getErrorMessage(error);
const handlePress = useCallback(() => {
setIsOpen((prevIsOpen) => !prevIsOpen);
}, []);
const handleSearchInputChange = useCallback(
({ value }: InputChanged<string>) => {
if (seriesLookupTimeout.current) {
clearTimeout(seriesLookupTimeout.current);
}
setTerm(value);
seriesLookupTimeout.current = setTimeout(() => {
dispatch(
queueLookupSeries({
name: id,
term: value,
topOfQueue: true,
})
);
}, 200);
},
[id, dispatch]
);
const handleRefreshPress = useCallback(() => {
dispatch(
queueLookupSeries({
name: id,
term,
topOfQueue: true,
})
);
}, [id, term, dispatch]);
const handleSeriesSelect = useCallback(
(tvdbId: number) => {
setIsOpen(false);
const selectedSeries = items.find((item) => item.tvdbId === tvdbId)!;
dispatch(
// @ts-expect-error - actions are not typed
setImportSeriesValue({
id,
selectedSeries,
})
);
if (selectedSeries.seriesType !== 'standard') {
onInputChange({
name: 'seriesType',
value: selectedSeries.seriesType,
});
}
},
[id, items, dispatch, onInputChange]
);
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 (
<>
<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 ? (
<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}
/>
{translate('NoMatchFound')}
</div>
) : null}
{!isFetching && !!error ? (
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('SearchFailedError')}
</div>
) : null}
<div className={styles.dropdownArrowContainer}>
<Icon name={icons.CARET_DOWN} />
</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>
<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>
) : null}
</div>
</FloatingPortal>
) : null}
</>
);
}
export default ImportSeriesSelectSeries;

View File

@@ -1,87 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
import * as seriesTypes from 'Utilities/Series/seriesTypes';
import ImportSeriesSelectSeries from './ImportSeriesSelectSeries';
function createMapStateToProps() {
return createSelector(
(state) => state.importSeries.isLookingUpSeries,
createImportSeriesItemSelector(),
(isLookingUpSeries, item) => {
return {
isLookingUpSeries,
...item
};
}
);
}
const mapDispatchToProps = {
queueLookupSeries,
setImportSeriesValue
};
class ImportSeriesSelectSeriesConnector extends Component {
//
// Listeners
onSearchInputChange = (term) => {
this.props.queueLookupSeries({
name: this.props.id,
term,
topOfQueue: true
});
};
onSeriesSelect = (tvdbId) => {
const {
id,
items,
onInputChange
} = this.props;
const selectedSeries = items.find((item) => item.tvdbId === tvdbId);
this.props.setImportSeriesValue({
id,
selectedSeries
});
if (selectedSeries.seriesType !== seriesTypes.STANDARD) {
onInputChange({
name: 'seriesType',
value: selectedSeries.seriesType
});
}
};
//
// Render
render() {
return (
<ImportSeriesSelectSeries
{...this.props}
onSearchInputChange={this.onSearchInputChange}
onSeriesSelect={this.onSeriesSelect}
/>
);
}
}
ImportSeriesSelectSeriesConnector.propTypes = {
id: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
selectedSeries: PropTypes.object,
isSelected: PropTypes.bool,
onInputChange: PropTypes.func.isRequired,
queueLookupSeries: PropTypes.func.isRequired,
setImportSeriesValue: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectSeriesConnector);

View File

@@ -1,57 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ImportSeriesTitle.css';
function ImportSeriesTitle(props) {
const {
title,
year,
network,
isExistingSeries
} = props;
return (
<div className={styles.titleContainer}>
<div className={styles.title}>
{title}
</div>
{
!title.contains(year) &&
year > 0 ?
<span className={styles.year}>
({year})
</span> :
null
}
{
network ?
<Label>{network}</Label> :
null
}
{
isExistingSeries ?
<Label
kind={kinds.WARNING}
>
{translate('Existing')}
</Label> :
null
}
</div>
);
}
ImportSeriesTitle.propTypes = {
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
network: PropTypes.string,
isExistingSeries: PropTypes.bool.isRequired
};
export default ImportSeriesTitle;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ImportSeriesTitle.css';
interface ImportSeriesTitleProps {
title: string;
year: number;
network?: string;
isExistingSeries: boolean;
}
function ImportSeriesTitle({
title,
year,
network,
isExistingSeries,
}: ImportSeriesTitleProps) {
return (
<div className={styles.titleContainer}>
<div className={styles.title}>{title}</div>
{year > 0 && !title.includes(String(year)) ? (
<span className={styles.year}>({year})</span>
) : null}
{network ? <Label>{network}</Label> : null}
{isExistingSeries ? (
<Label kind={kinds.WARNING}>{translate('Existing')}</Label>
) : null}
</div>
);
}
export default ImportSeriesTitle;

View File

@@ -1,30 +0,0 @@
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import ImportSeriesConnector from 'AddSeries/ImportSeries/Import/ImportSeriesConnector';
import ImportSeriesSelectFolderConnector from 'AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector';
import Switch from 'Components/Router/Switch';
class ImportSeries extends Component {
//
// Render
render() {
return (
<Switch>
<Route
exact={true}
path="/add/import"
component={ImportSeriesSelectFolderConnector}
/>
<Route
path="/add/import/:rootFolderId"
component={ImportSeriesConnector}
/>
</Switch>
);
}
}
export default ImportSeries;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Route } from 'react-router-dom';
import Switch from 'Components/Router/Switch';
import ImportSeries from './Import/ImportSeries';
import ImportSeriesSelectFolder from './SelectFolder/ImportSeriesSelectFolder';
function ImportSeriesPage() {
return (
<Switch>
<Route
exact={true}
path="/add/import"
component={ImportSeriesSelectFolder}
/>
<Route path="/add/import/:rootFolderId" component={ImportSeries} />
</Switch>
);
}
export default ImportSeriesPage;

View File

@@ -1,188 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { icons, kinds, sizes } from 'Helpers/Props';
import RootFolders from 'RootFolder/RootFolders';
import translate from 'Utilities/String/translate';
import styles from './ImportSeriesSelectFolder.css';
class ImportSeriesSelectFolder extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddNewRootFolderModalOpen: false
};
}
//
// Lifecycle
onAddNewRootFolderPress = () => {
this.setState({ isAddNewRootFolderModalOpen: true });
};
onNewRootFolderSelect = ({ value }) => {
this.props.onNewRootFolderSelect(value);
};
onAddRootFolderModalClose = () => {
this.setState({ isAddNewRootFolderModalOpen: false });
};
//
// Render
render() {
const {
isWindows,
isFetching,
isPopulated,
isSaving,
error,
saveError,
items
} = this.props;
const hasRootFolders = items.length > 0;
const goodFolderExample = (isWindows) ? 'C:\\tv shows' : '/tv shows';
const badFolderExample = (isWindows) ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons';
return (
<PageContent title={translate('ImportSeries')}>
<PageContentBody>
{
isFetching && !isPopulated ?
<LoadingIndicator /> :
null
}
{
!isFetching && error ?
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert> :
null
}
{
!error && isPopulated &&
<div>
<div className={styles.header}>
{translate('LibraryImportSeriesHeader')}
</div>
<div className={styles.tips}>
{translate('LibraryImportTips')}
<ul>
<li className={styles.tip}>
<InlineMarkdown data={translate('LibraryImportTipsQualityInEpisodeFilename')} />
</li>
<li className={styles.tip}>
<InlineMarkdown data={translate('LibraryImportTipsSeriesUseRootFolder', { goodFolderExample, badFolderExample })} />
</li>
<li className={styles.tip}>
{translate('LibraryImportTipsDontUseDownloadsFolder')}
</li>
</ul>
</div>
{
hasRootFolders ?
<div className={styles.recentFolders}>
<FieldSet legend={translate('RootFolders')}>
<RootFolders
isFetching={isFetching}
isPopulated={isPopulated}
error={error}
items={items}
/>
</FieldSet>
</div> :
null
}
{
!isSaving && saveError ?
<Alert
className={styles.addErrorAlert}
kind={kinds.DANGER}
>
{translate('AddRootFolderError')}
<ul>
{
Array.isArray(saveError.responseJSON) ?
saveError.responseJSON.map((e, index) => {
return (
<li key={index}>
{e.errorMessage}
</li>
);
}) :
<li>
{
JSON.stringify(saveError.responseJSON)
}
</li>
}
</ul>
</Alert> :
null
}
<div className={hasRootFolders ? undefined : styles.startImport}>
<Button
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={this.onAddNewRootFolderPress}
>
<Icon
className={styles.importButtonIcon}
name={icons.DRIVE}
/>
{
hasRootFolders ?
translate('ChooseAnotherFolder') :
translate('StartImport')
}
</Button>
</div>
<FileBrowserModal
isOpen={this.state.isAddNewRootFolderModalOpen}
name="rootFolderPath"
value=""
onChange={this.onNewRootFolderSelect}
onModalClose={this.onAddRootFolderModalClose}
/>
</div>
}
</PageContentBody>
</PageContent>
);
}
}
ImportSeriesSelectFolder.propTypes = {
isWindows: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
error: PropTypes.object,
saveError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onNewRootFolderSelect: PropTypes.func.isRequired
};
export default ImportSeriesSelectFolder;

View File

@@ -0,0 +1,164 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, kinds, sizes } from 'Helpers/Props';
import RootFolders from 'RootFolder/RootFolders';
import {
addRootFolder,
fetchRootFolders,
} from 'Store/Actions/rootFolderActions';
import useIsWindows from 'System/useIsWindows';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './ImportSeriesSelectFolder.css';
function ImportSeriesSelectFolder() {
const dispatch = useDispatch();
const { isFetching, isPopulated, isSaving, error, saveError, items } =
useSelector((state: AppState) => state.rootFolders);
const isWindows = useIsWindows();
const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
useState(false);
const wasSaving = usePrevious(isSaving);
const hasRootFolders = items.length > 0;
const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows';
const badFolderExample = isWindows
? 'C:\\tv shows\\the simpsons'
: '/tv shows/the simpsons';
const handleAddNewRootFolderPress = useCallback(() => {
setIsAddNewRootFolderModalOpen(true);
}, []);
const handleAddRootFolderModalClose = useCallback(() => {
setIsAddNewRootFolderModalOpen(false);
}, []);
const handleNewRootFolderSelect = useCallback(
({ value }: InputChanged<string>) => {
dispatch(addRootFolder({ path: value }));
},
[dispatch]
);
useEffect(() => {
dispatch(fetchRootFolders());
}, [dispatch]);
useEffect(() => {
if (!isSaving && wasSaving && !saveError) {
items.reduce((acc, item) => {
if (item.id > acc) {
return item.id;
}
return acc;
}, 0);
}
}, [isSaving, wasSaving, saveError, items]);
return (
<PageContent title={translate('ImportSeries')}>
<PageContentBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert>
) : null}
{!error && isPopulated && (
<div>
<div className={styles.header}>
{translate('LibraryImportSeriesHeader')}
</div>
<div className={styles.tips}>
{translate('LibraryImportTips')}
<ul>
<li className={styles.tip}>
<InlineMarkdown
data={translate(
'LibraryImportTipsQualityInEpisodeFilename'
)}
/>
</li>
<li className={styles.tip}>
<InlineMarkdown
data={translate('LibraryImportTipsSeriesUseRootFolder', {
goodFolderExample,
badFolderExample,
})}
/>
</li>
<li className={styles.tip}>
{translate('LibraryImportTipsDontUseDownloadsFolder')}
</li>
</ul>
</div>
{hasRootFolders ? (
<div className={styles.recentFolders}>
<FieldSet legend={translate('RootFolders')}>
<RootFolders />
</FieldSet>
</div>
) : null}
{!isSaving && saveError ? (
<Alert className={styles.addErrorAlert} kind={kinds.DANGER}>
{translate('AddRootFolderError')}
<ul>
{Array.isArray(saveError.responseJSON) ? (
saveError.responseJSON.map((e, index) => {
return <li key={index}>{e.errorMessage}</li>;
})
) : (
<li>{JSON.stringify(saveError.responseJSON)}</li>
)}
</ul>
</Alert>
) : null}
<div className={hasRootFolders ? undefined : styles.startImport}>
<Button
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={handleAddNewRootFolderPress}
>
<Icon className={styles.importButtonIcon} name={icons.DRIVE} />
{hasRootFolders
? translate('ChooseAnotherFolder')
: translate('StartImport')}
</Button>
</div>
<FileBrowserModal
isOpen={isAddNewRootFolderModalOpen}
name="rootFolderPath"
value=""
onChange={handleNewRootFolderSelect}
onModalClose={handleAddRootFolderModalClose}
/>
</div>
)}
</PageContentBody>
</PageContent>
);
}
export default ImportSeriesSelectFolder;

View File

@@ -1,85 +0,0 @@
import { push } from 'connected-react-router';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addRootFolder, fetchRootFolders } from 'Store/Actions/rootFolderActions';
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
function createMapStateToProps() {
return createSelector(
createRootFoldersSelector(),
createSystemStatusSelector(),
(rootFolders, systemStatus) => {
return {
...rootFolders,
isWindows: systemStatus.isWindows
};
}
);
}
const mapDispatchToProps = {
fetchRootFolders,
addRootFolder,
push
};
class ImportSeriesSelectFolderConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchRootFolders();
}
componentDidUpdate(prevProps) {
const {
items,
isSaving,
saveError
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id);
if (newRootFolders.length === 1) {
this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`);
}
}
}
//
// Listeners
onNewRootFolderSelect = (path) => {
this.props.addRootFolder({ path });
};
//
// Render
render() {
return (
<ImportSeriesSelectFolder
{...this.props}
onNewRootFolderSelect={this.onNewRootFolderSelect}
/>
);
}
}
ImportSeriesSelectFolderConnector.propTypes = {
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchRootFolders: PropTypes.func.isRequired,
addRootFolder: PropTypes.func.isRequired,
push: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectFolderConnector);

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

@@ -4,7 +4,7 @@ import React from 'react';
import DocumentTitle from 'react-document-title';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import PageConnector from 'Components/Page/PageConnector';
import Page from 'Components/Page/Page';
import ApplyTheme from './ApplyTheme';
import AppRoutes from './AppRoutes';
@@ -22,9 +22,9 @@ function App({ store, history }: AppProps) {
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<Page>
<AppRoutes />
</PageConnector>
</Page>
</ConnectedRouter>
</Provider>
</QueryClientProvider>

View File

@@ -3,36 +3,36 @@ import { Redirect, Route } from 'react-router-dom';
import Blocklist from 'Activity/Blocklist/Blocklist';
import History from 'Activity/History/History';
import Queue from 'Activity/Queue/Queue';
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
import AddNewSeries from 'AddSeries/AddNewSeries/AddNewSeries';
import ImportSeriesPage from 'AddSeries/ImportSeries/ImportSeriesPage';
import CalendarPage from 'Calendar/CalendarPage';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
import SeriesDetailsPage from 'Series/Details/SeriesDetailsPage';
import SeriesIndex from 'Series/Index/SeriesIndex';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
import DownloadClientSettings from 'Settings/DownloadClients/DownloadClientSettings';
import GeneralSettings from 'Settings/General/GeneralSettings';
import ImportListSettings from 'Settings/ImportLists/ImportListSettings';
import IndexerSettings from 'Settings/Indexers/IndexerSettings';
import MediaManagement from 'Settings/MediaManagement/MediaManagement';
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings';
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
import Profiles from 'Settings/Profiles/Profiles';
import QualityConnector from 'Settings/Quality/QualityConnector';
import Quality from 'Settings/Quality/Quality';
import Settings from 'Settings/Settings';
import TagSettings from 'Settings/Tags/TagSettings';
import UISettingsConnector from 'Settings/UI/UISettingsConnector';
import BackupsConnector from 'System/Backup/BackupsConnector';
import LogsTableConnector from 'System/Events/LogsTableConnector';
import UISettings from 'Settings/UI/UISettings';
import Backups from 'System/Backup/Backups';
import LogsTable from 'System/Events/LogsTable';
import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import Updates from 'System/Updates/Updates';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
import CutoffUnmet from 'Wanted/CutoffUnmet/CutoffUnmet';
import Missing from 'Wanted/Missing/Missing';
function RedirectWithUrlBase() {
return <Redirect to={getPathWithUrlBase('/')} />;
@@ -58,15 +58,15 @@ function AppRoutes() {
/>
)}
<Route path="/add/new" component={AddNewSeriesConnector} />
<Route path="/add/new" component={AddNewSeries} />
<Route path="/add/import" component={ImportSeries} />
<Route path="/add/import" component={ImportSeriesPage} />
<Route path="/serieseditor" exact={true} render={RedirectWithUrlBase} />
<Route path="/seasonpass" exact={true} render={RedirectWithUrlBase} />
<Route path="/series/:titleSlug" component={SeriesDetailsPageConnector} />
<Route path="/series/:titleSlug" component={SeriesDetailsPage} />
{/*
Calendar
@@ -88,9 +88,9 @@ function AppRoutes() {
Wanted
*/}
<Route path="/wanted/missing" component={MissingConnector} />
<Route path="/wanted/missing" component={Missing} />
<Route path="/wanted/cutoffunmet" component={CutoffUnmetConnector} />
<Route path="/wanted/cutoffunmet" component={CutoffUnmet} />
{/*
Settings
@@ -98,31 +98,25 @@ function AppRoutes() {
<Route exact={true} path="/settings" component={Settings} />
<Route
path="/settings/mediamanagement"
component={MediaManagementConnector}
/>
<Route path="/settings/mediamanagement" component={MediaManagement} />
<Route path="/settings/profiles" component={Profiles} />
<Route path="/settings/quality" component={QualityConnector} />
<Route path="/settings/quality" component={Quality} />
<Route
path="/settings/customformats"
component={CustomFormatSettingsPage}
/>
<Route path="/settings/indexers" component={IndexerSettingsConnector} />
<Route path="/settings/indexers" component={IndexerSettings} />
<Route
path="/settings/downloadclients"
component={DownloadClientSettingsConnector}
component={DownloadClientSettings}
/>
<Route
path="/settings/importlists"
component={ImportListSettingsConnector}
/>
<Route path="/settings/importlists" component={ImportListSettings} />
<Route path="/settings/connect" component={NotificationSettings} />
@@ -135,9 +129,9 @@ function AppRoutes() {
<Route path="/settings/tags" component={TagSettings} />
<Route path="/settings/general" component={GeneralSettingsConnector} />
<Route path="/settings/general" component={GeneralSettings} />
<Route path="/settings/ui" component={UISettingsConnector} />
<Route path="/settings/ui" component={UISettings} />
{/*
System
@@ -147,11 +141,11 @@ function AppRoutes() {
<Route path="/system/tasks" component={Tasks} />
<Route path="/system/backup" component={BackupsConnector} />
<Route path="/system/backup" component={Backups} />
<Route path="/system/updates" component={Updates} />
<Route path="/system/events" component={LogsTableConnector} />
<Route path="/system/events" component={LogsTable} />
<Route path="/system/logs/files" component={Logs} />

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
@@ -9,8 +9,8 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
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';
@@ -63,30 +63,23 @@ 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, refetch } = 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`;
}, []);
useEffect(() => {
dispatch(fetchUpdates());
}, [dispatch]);
useEffect(() => {
if (version !== previousVersion) {
dispatch(fetchUpdates());
refetch();
}
}, [version, previousVersion, dispatch]);
}, [version, previousVersion, refetch]);
return (
<ModalContent onModalClose={onModalClose}>
@@ -100,7 +93,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
/>
</div>
{isPopulated && !error && !!update ? (
{isFetched && !error && !!update ? (
<div>
{update.changes ? (
<div className={styles.maintenance}>
@@ -126,7 +119,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,6 +1,9 @@
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect } from 'react';
import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState';
import useSelectState, {
SelectState,
SelectStateModel,
} from 'Helpers/Hooks/useSelectState';
import ModelBase from './ModelBase';
export type SelectContextAction =
@@ -9,13 +12,13 @@ export type SelectContextAction =
| { type: 'unselectAll' }
| {
type: 'toggleSelected';
id: number;
isSelected: boolean;
id: number | string;
isSelected: boolean | null;
shiftKey: boolean;
}
| {
type: 'removeItem';
id: number;
id: number | string;
}
| {
type: 'updateItems';
@@ -24,7 +27,7 @@ export type SelectContextAction =
export type SelectDispatch = (action: SelectContextAction) => void;
interface SelectProviderOptions<T extends ModelBase> {
interface SelectProviderOptions<T extends SelectStateModel> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: any;
items: Array<T>;
@@ -34,7 +37,7 @@ const SelectContext = React.createContext<
[SelectState, SelectDispatch] | undefined
>(cloneDeep(undefined));
export function SelectProvider<T extends ModelBase>(
export function SelectProvider<T extends SelectStateModel>(
props: SelectProviderOptions<T>
) {
const { items } = props;

View File

@@ -1,7 +1,7 @@
import Column from 'Components/Table/Column';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { ValidationFailure } from 'typings/pending';
import { FilterBuilderProp, PropertyFilter } from './AppState';
import { Filter, FilterBuilderProp } from './AppState';
export interface Error {
status?: number;
@@ -35,7 +35,7 @@ export interface TableAppSectionState {
export interface AppSectionFilterState<T> {
selectedFilterKey: string;
filters: PropertyFilter[];
filters: Filter[];
filterBuilderProps: FilterBuilderProp<T>[];
}
@@ -43,9 +43,8 @@ export interface AppSectionSchemaState<T> {
isSchemaFetching: boolean;
isSchemaPopulated: boolean;
schemaError: Error;
schema: {
items: T[];
};
schema: T[];
selectedSchema?: T;
}
export interface AppSectionItemSchemaState<T> {
@@ -63,14 +62,23 @@ export interface AppSectionItemState<T> {
item: T;
}
export interface AppSectionListState<T> {
isFetching: boolean;
isPopulated: boolean;
error: Error;
items: T[];
pendingChanges: Partial<T>[];
}
export interface AppSectionProviderState<T>
extends AppSectionDeleteState,
AppSectionSaveState {
isFetching: boolean;
isPopulated: boolean;
isTesting?: boolean;
error: Error;
items: T[];
pendingChanges: Partial<T>;
pendingChanges?: Partial<T>;
}
interface AppSectionState<T> {

View File

@@ -1,16 +1,23 @@
import ModelBase from 'App/ModelBase';
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
import { Error } from './AppSectionState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState';
import CustomFiltersAppState from './CustomFiltersAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState';
import HistoryAppState from './HistoryAppState';
import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState';
import ImportSeriesAppState from './ImportSeriesAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import MessagesAppState from './MessagesAppState';
import OAuthAppState from './OAuthAppState';
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';
@@ -19,41 +26,43 @@ import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState';
import WantedAppState from './WantedAppState';
interface FilterBuilderPropOption {
export interface FilterBuilderPropOption {
id: string;
name: string;
}
export interface FilterBuilderProp<T> {
name: string;
label: string;
type: string;
label: string | (() => string);
type: FilterBuilderTypes;
valueType?: string;
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
}
export interface PropertyFilter {
key: string;
value: boolean | string | number | string[] | number[];
type: string;
value: string | string[] | number[] | boolean[] | DateFilterValue;
type: FilterType;
}
export interface Filter {
key: string;
label: string;
label: string | (() => string);
filters: PropertyFilter[];
}
export interface CustomFilter {
id: number;
export interface CustomFilter extends ModelBase {
type: string;
label: string;
filters: PropertyFilter[];
}
export interface AppSectionState {
isUpdated: boolean;
isConnected: boolean;
isDisconnected: boolean;
isReconnecting: boolean;
isRestarting: boolean;
isSidebarVisible: boolean;
version: string;
prevVersion?: string;
@@ -63,6 +72,11 @@ export interface AppSectionState {
width: number;
height: number;
};
translations: {
error?: Error;
isPopulated: boolean;
};
messages: MessagesAppState;
}
interface AppState {
@@ -71,20 +85,23 @@ interface AppState {
calendar: CalendarAppState;
captcha: CaptchaAppState;
commands: CommandAppState;
customFilters: CustomFiltersAppState;
episodeFiles: EpisodeFilesAppState;
episodeHistory: HistoryAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;
importSeries: ImportSeriesAppState;
interactiveImport: InteractiveImportAppState;
oAuth: OAuthAppState;
organizePreview: OrganizePreviewAppState;
parse: ParseAppState;
paths: PathsAppState;
providerOptions: ProviderOptionsAppState;
queue: QueueAppState;
releases: ReleasesAppState;
rootFolders: RootFolderAppState;
series: SeriesAppState;
seriesHistory: SeriesHistoryAppState;
seriesIndex: SeriesIndexAppState;
settings: SettingsAppState;
system: SystemAppState;

View File

@@ -0,0 +1,9 @@
import Backup from 'typings/Backup';
import AppSectionState, { Error } from './AppSectionState';
interface BackupAppState extends AppSectionState<Backup> {
isRestoring: boolean;
restoreError?: Error;
}
export default BackupAppState;

View File

@@ -1,10 +1,12 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import { CustomFilter } from './AppState';
interface CustomFiltersAppState
extends AppSectionState<CustomFilter>,
AppSectionDeleteState {}
AppSectionDeleteState,
AppSectionSaveState {}
export default CustomFiltersAppState;

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