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

Compare commits

...

587 Commits

Author SHA1 Message Date
Mark McDowall
2483694a2a New: Delete files for Select Series
Closes #5110
2026-03-02 19:57:24 -08:00
Yashizzle
7f971d47ac New: Include External IDs for series with links
Closes #7927
2026-03-01 09:48:15 -08:00
Sonarr
db9ef92a80 Automated API Docs update
ignore-downstream
2026-03-01 09:47:17 -08:00
Weblate
147f11dece Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Andreu Punsola Soler <andreu4ps@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translation: Servarr/Sonarr
2026-03-01 09:30:25 -08:00
Mark McDowall
f91ebd4c07 New: Do not automatically import multi-season releases
Closes #8133
2026-03-01 09:30:07 -08:00
Stevie Robinson
d99f8b5685 New: Warning on add series list for Import list exclusions
Closes #8433
2026-03-01 09:05:01 -08:00
Mark McDowall
6c329e8a6f Remove English limitation for aliases 2026-03-01 09:04:30 -08:00
Mark McDowall
2a5667e634 Fixed: Consistent diacrital removal from clean titles
Closes #8424
2026-03-01 09:04:30 -08:00
Mark McDowall
da6340e421 New: Add Original Country information
Closes #5143
2026-03-01 09:04:19 -08:00
Mark McDowall
965b6144e3 New: Improve parsing of releases with contain multiple titles 2026-03-01 09:04:01 -08:00
Bogdan
7add5aafad Bump lodash, qs, rimraf, html-webpack-plugin and webpack 2026-03-01 09:03:53 -08:00
Stevie Robinson
d543427012 New: Improve acceptable size rejection messaging for multi-episode releases 2026-03-01 09:03:10 -08:00
Mark McDowall
1c805bded0 Clean up SignalRListener 2026-03-01 09:02:36 -08:00
Mark McDowall
c4c0ec25ac Use react-query for Indexers 2026-03-01 09:02:36 -08:00
Mark McDowall
bcceb22512 Add v5 Indexer endpoints 2026-03-01 09:02:36 -08:00
Mark McDowall
6764cf1c22 Use react-query for General settings 2026-03-01 09:02:24 -08:00
Mark McDowall
dd6533c18a Add v5 General settings endpoints 2026-03-01 09:02:24 -08:00
Mark McDowall
ac1c74105f Cleanup settings controllers 2026-03-01 09:02:24 -08:00
felizk
33fb0a4e88 New: Calculate custom score using renamed filename before importing 2026-03-01 09:02:13 -08:00
Sonarr
f93bc57426 Automated API Docs update
ignore-downstream
2026-03-01 09:00:45 -08:00
Weblate
eda676f9a2 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Deleted User <noreply+5063@weblate.org>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Jonas <Sjokoladeergodt@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: jeff20001204 <jeff20001204@gmail.com>
Co-authored-by: lalafei524 <kaba0524@qq.com>
Co-authored-by: ugyes <ferenc.bodi@live.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_TW/
Translation: Servarr/Sonarr
2026-03-01 09:00:39 -08:00
Mark McDowall
c8f15ae198 Close issues that don't follow issue templates 2026-02-23 09:07:25 -08:00
Mark McDowall
fcfdac99d5 New: Parse series if year is added to known alias
Closes #8408
2026-02-16 10:48:23 -08:00
Mark McDowall
5bac016f0c Use react-query for Languages 2026-02-16 10:48:03 -08:00
Mark McDowall
236978a9b1 Add v5 Language endpoints 2026-02-16 10:48:03 -08:00
Mark McDowall
d8698d1c28 Fixed: Ensure trailing slash is removed from drive letters when updating Plex Media Server
Closes #8414
2026-02-16 10:26:58 -08:00
Mark McDowall
b94cf9b3b9 New: Skip series search for seasons with files if profile doesn't allow upgrades 2026-02-16 10:26:52 -08:00
Mark McDowall
2335657fe4 New: Parse NCOP/NCED as specials 2026-02-16 10:25:53 -08:00
Mark McDowall
8fa16e3542 New: Add option to not download before air date to Release Profiles
Closes #969
2026-02-16 10:24:44 -08:00
Mark McDowall
93713c3827 New: Additional logging for recycle bin cleanup
Closes #8388
2026-02-16 10:24:28 -08:00
Mark McDowall
39573ea17b New: Use translations for days of week
Closes #8384
2026-02-16 10:23:45 -08:00
Mark McDowall
944e33f24b Convert getLanguageName to hook 2026-02-16 10:23:45 -08:00
Bogdan
06aba5fe18 Bump .NET to 10.0.3 2026-02-16 10:21:28 -08:00
Sonarr
49f6117d54 Automated API Docs update
ignore-downstream
2026-02-16 10:21:18 -08:00
Weblate
9c61a5c286 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Klein Moretti <computerunitad@hotmail.fr>
Co-authored-by: ole brum <wb@rebnord.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translation: Servarr/Sonarr
2026-02-16 10:21:09 -08:00
Stevie Robinson
fdda9abcbb Remove the default indexer definitions for AnimeTosho 2026-02-07 18:47:27 -08:00
Mark McDowall
dfbd54308f Remove naming from SettingsAppState 2026-02-07 18:47:02 -08:00
Mark McDowall
bbb4c6714c Use react-query for Media Management settings 2026-02-07 18:47:02 -08:00
Mark McDowall
1d8da79172 Add v5 Media Management endpoints 2026-02-07 18:47:02 -08:00
Weblate
3e22ad59c3 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: FunThomasSVK <t.kandrac19@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Yuval Yaron <yuvalyaron2011@gmail.com>
Co-authored-by: cicomalieran <cicom77@hotmail.com>
Co-authored-by: ctrl2027 <ctrl5862@gmail.com>
Co-authored-by: ugyes <ferenc.bodi@live.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/sk/
Translation: Servarr/Sonarr
2026-02-07 18:46:56 -08:00
Bogdan
f2fc1e9332 Display ratings when sorting on series posters index component 2026-01-30 17:25:30 -08:00
Bogdan
e8020b7289 Fix sorting direction on series index component 2026-01-30 17:25:30 -08:00
Bogdan
1033849d39 Fix translation key for banner in SeriesImage component 2026-01-30 17:25:30 -08:00
Bogdan
70678510ee Use theme appropriate colors for image background on initial load 2026-01-30 17:25:19 -08:00
Mark McDowall
54dafdb8d3 Reenable column selection for series 2026-01-30 17:25:01 -08:00
Mark McDowall
c0a565861e Use react-query for Metadata 2026-01-30 17:24:54 -08:00
Mark McDowall
8b8cd14834 Add v5 Metadata endpoints 2026-01-30 17:24:54 -08:00
Ivan Trubach
ee83b81be9 Reduce log noise in HttpHappyEyeballs 2026-01-30 17:24:45 -08:00
Bogdan
302b0f356e Fallback to video stream 0 when reading primary video for frame analysis 2026-01-30 17:24:33 -08:00
Weblate
15fb999597 Multiple Translations updated by Weblate
ignore-downstream

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/pt_BR/
Translation: Servarr/Sonarr
2026-01-30 17:24:14 -08:00
realzombee
b88b7e15c1 Fixed: Reduce data transfer when reading video stream from files 2026-01-26 20:43:09 -08:00
Sonarr
da6985b40c Automated API Docs update
ignore-downstream
2026-01-26 20:41:33 -08:00
Weblate
0d521c078b 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
2026-01-26 20:38:07 -08:00
Mark McDowall
52ce33a97a Fix manual import when season number is provided during reprocessing 2026-01-26 20:37:22 -08:00
Mark McDowall
1ef034af35 Skip absolute + standard parsing when hash is not present
Closes #8355
2026-01-26 20:37:12 -08:00
Stevie Robinson
7ea20335ed Fixed: undefined absolute episode numbers on select episode modal 2026-01-26 20:37:03 -08:00
Stevie Robinson
41b0ecb08c Increase width of Organize and Rename modal
Closes #8356
2026-01-26 20:36:24 -08:00
Bogdan
5d29c16bf3 Fix missing translations
Co-authored-by: Stevie Robinson <stevie.robinson@gmail.com>
2026-01-26 20:35:38 -08:00
Bogdan
210c20f8e9 Bump FluentMigrator to 8.0.1 2026-01-26 20:35:26 -08:00
Bogdan
0ed53874c6 Bump .NET to 10.0.2 2026-01-26 20:35:26 -08:00
Mark McDowall
97d3a4940d Add focus border to buttons
Closes #8344
2026-01-26 20:35:11 -08:00
Mark McDowall
98fbe694e4 Validate Special Folder Format doesn't match excluded folder
Closes #8339
2026-01-26 20:35:04 -08:00
Mark McDowall
677c588a3b Use react-query for Naming settings 2026-01-26 20:35:04 -08:00
Mark McDowall
0ddc2a34e5 Add v5 Naming endpoints 2026-01-26 20:35:04 -08:00
Bogdan
f3b39ba4f7 New: Display multiple audio and subtitles streams in media info 2026-01-26 20:34:54 -08:00
Bogdan
c409ee81bd Bump FFprobeStatic to v8.0.1 (build 302)
update dependency videolan/dav1d 1.5.3
2026-01-26 20:34:54 -08:00
TypNull
6cd1ab3764 Improve external restart handling
Co-authored-by: Meyn <loads@gmx.de>
2026-01-26 20:34:35 -08:00
Ivan Trubach
5d8d2d66a4 Use Happy Eyeballs for HTTP socket address selection 2026-01-26 20:34:03 -08:00
Bogdan
164441965c New: Selecting multiple indexers per release profile
Closes #7706
2026-01-26 20:32:57 -08:00
Weblate
5487aa74f5 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Mateusz Lesiak <mateusz.lesiak01@gmail.com>
Co-authored-by: NoEomtionY <13435201967@163.com>
Co-authored-by: Simeon-byte <saimjen.duglas@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: ugyes <ferenc.bodi@live.com>
Co-authored-by: wangtelong777 <wangtelong777@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pl/
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/zh_Hans/
Translation: Servarr/Sonarr
2026-01-26 20:32:33 -08:00
Mark McDowall
bee7e4325f New: Parse non-ASCII release groups
Closes #8341
2026-01-12 18:59:53 -08:00
Mark McDowall
7b0db46c25 New: Improve parsing of non-mini series releases containing "Part #" in the episode name 2026-01-12 18:59:53 -08:00
Mark McDowall
b16743bdda Fixed: Don't parse release group from episode title when it contains a dash 2026-01-12 18:59:53 -08:00
Mark McDowall
399ca1661f New: Ability to set command priority when starting command via API 2026-01-12 18:59:48 -08:00
Mark McDowall
28300ffb2b Add aria-labels to sidebar status 2026-01-12 18:59:35 -08:00
Mark McDowall
ae13ce4ac6 Improve tab behaviour 2026-01-12 18:59:35 -08:00
Mark McDowall
276e67b5fa Add alt text for series images
Closes #8332
2026-01-12 18:59:35 -08:00
Mark McDowall
e20b4c4f5d Update error message when connections fail to load 2026-01-12 18:59:26 -08:00
Mark McDowall
6d49b41dd2 Use react-query for Connections 2026-01-12 18:59:26 -08:00
Mark McDowall
0d80c093ff Prevent empty path from breaking custom script validation 2026-01-12 18:59:26 -08:00
Mark McDowall
06c6062531 Add v5 Connection endpoints 2026-01-12 18:59:26 -08:00
Weblate
3e8a85ad26 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>
Co-authored-by: ugyes <ferenc.bodi@live.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/hu/
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
2026-01-12 18:59:06 -08:00
Mark McDowall
107e474f9c Parse additional reversed filename format 2026-01-06 19:15:17 -08:00
Mark McDowall
0149886b68 Remove Custom Filters from fetch server collection 2026-01-06 19:15:07 -08:00
Bogdan
77612810d8 Fix finding last command run 2026-01-06 19:14:57 -08:00
Bogdan
12ed8cdc26 Bump BusyTimeout for SQLite to 1000ms 2026-01-06 19:14:37 -08:00
Mark McDowall
63431917fe Fixed: Improve parsing of WEB 2160p releases
Closes #8324
2026-01-06 19:14:29 -08:00
Mark McDowall
e06c6ba649 Fixed: Parsing multiple subtitle languages
Closes #8322
2026-01-06 19:14:29 -08:00
Bogdan
e747ec8f5c Fix disposing of the HttpRequestMessage 2026-01-06 19:13:57 -08:00
Bogdan
6a6639105e New: API key support for qBittorrent
Closes #8282
2026-01-06 19:13:25 -08:00
Bogdan
8f5f3070ac Fixed: Login with credentials on Qbittorrent 5.2 2026-01-06 19:12:58 -08:00
Bogdan
1839aa7907 Add Threshold to translations 2026-01-06 19:12:17 -08:00
Bogdan
a79234de48 Remove constraint for TvRageId from Series table 2026-01-06 19:12:01 -08:00
Weblate
ac5b9b14ee 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: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: ugyes <ferenc.bodi@live.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
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/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translation: Servarr/Sonarr
2026-01-06 19:11:05 -08:00
Sonarr
e0abca8360 Automated API Docs update
ignore-downstream
2026-01-01 09:38:09 -08:00
Mark McDowall
b2f2c21a61 New: Return error if invalid indexer or download client provided for pushed releases 2026-01-01 09:33:50 -08:00
Bogdan
4bf02221e6 Prevent NullRefs in series event handlers 2026-01-01 09:33:20 -08:00
Bogdan
c70de927a5 Avoid unique constraints for primary keys in SQLite migrations 2026-01-01 09:32:54 -08:00
Mark McDowall
c60a978eb8 Fix fetching indexers for release profiles 2026-01-01 09:32:42 -08:00
Mark McDowall
cf593b1f5d Use react-query for Quality Definitions 2026-01-01 09:32:36 -08:00
Mark McDowall
243a3057ae Add v5 Quality Definition endpoints 2026-01-01 09:32:36 -08:00
Weblate
8b438c2197 Multiple Translations updated by Weblate
ignore-downstream

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/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2026-01-01 09:32:17 -08:00
Mark McDowall
21ca65a015 Use react-query for Quality Profiles 2025-12-30 16:37:21 -08:00
Mark McDowall
f4b9b30978 Add v5 Quality Profiles endpoints 2025-12-30 16:37:21 -08:00
Mark McDowall
4713615b17 Use react-query for Release Profiles 2025-12-29 23:44:53 -08:00
Mark McDowall
f963a0d972 Add v5 Release Profiles endpoints 2025-12-29 23:44:53 -08:00
Sonarr
3977d8766c Automated API Docs update
ignore-downstream
2025-12-29 13:08:52 -08:00
Mark McDowall
91f1b672c5 Set page background color based on theme 2025-12-29 13:04:39 -08:00
Mark McDowall
c3706c3c92 Optionally show mini profiler 2025-12-29 13:04:39 -08:00
Mark McDowall
8fcab2d321 Use react-query for Remote Path Mappings 2025-12-29 13:04:31 -08:00
Mark McDowall
1114cc7f7a Add v5 Remote Path Mappings endpoints 2025-12-29 13:04:31 -08:00
Mark McDowall
74e6ce4305 Use react-query for UI settings 2025-12-29 10:58:00 -08:00
Mark McDowall
e9011011ed Add v5 UI settings endpoints 2025-12-29 10:58:00 -08:00
Mark McDowall
7e70238005 Convert advanced settings to zustand store 2025-12-29 10:58:00 -08:00
Mark McDowall
ad57cf4b5d Use react-query for series import 2025-12-29 10:57:50 -08:00
Mark McDowall
25fb4c4d7a Add v5 series import endpoints 2025-12-29 10:57:50 -08:00
Mark McDowall
2d071eca9b Move unknown series queue items to filter instead of option 2025-12-29 10:57:40 -08:00
Mark McDowall
a466a94d4d Clean up release group exception regex 2025-12-29 10:57:30 -08:00
Roman Hlushchak
763c9c838f Fixed: Parse additional formats of multi-episode with episodes in brackets 2025-12-29 10:57:11 -08:00
Weblate
3f098c601b 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-12-29 10:56:56 -08:00
Mark McDowall
c5ff89c69b Clean up AppState 2025-12-28 16:49:36 -08:00
Mark McDowall
cd7adba17c Use react-query for provider options 2025-12-28 16:49:36 -08:00
Mark McDowall
5f8297da6c New: Series custom filter for Monitored Episodes
Closes #7552
2025-12-28 16:48:21 -08:00
Mark McDowall
ee875ae654 New: Removed Special Handling of Reflinks for BTRFS and ZFS
Closes #7946
2025-12-28 16:48:08 -08:00
Weblate
d70dcfed56 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
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/
Translation: Servarr/Sonarr
2025-12-28 16:47:23 -08:00
Sonarr
47fa1f3ae6 Automated API Docs update
ignore-downstream
2025-12-26 10:26:22 -08:00
Mark McDowall
6bb694a6f7 New: Fallback to alternate download clients on failure
Closes #6861
2025-12-26 10:26:16 -08:00
Mark McDowall
3c77c4b989 Use hook for OAuth input 2025-12-25 21:05:44 -08:00
Weblate
fe61545716 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Pawel Jelonek <jelonek.pawel@protonmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pl/
Translation: Servarr/Sonarr
2025-12-25 21:05:36 -08:00
Mark McDowall
dbb841f027 Use 'includeSubResources' to include sub resources in responses 2025-12-25 20:49:55 -08:00
Mark McDowall
8ee10adbd4 New: Calendar Custom Filter for specials
Closes #8275
2025-12-25 20:49:55 -08:00
Bogdan
03cb2e7f63 Fix translate call in release options store 2025-12-25 20:49:43 -08:00
Bogdan
65cd80a9d5 Bump Npgsql and Swashbuckle 2025-12-25 20:49:43 -08:00
Bogdan
869269ddf3 Bump fontawesome, babel, core-js, rimraf and browserlist db 2025-12-25 20:49:43 -08:00
Bogdan
ff36fb017a Refetch episode files on series refresh 2025-12-25 20:49:34 -08:00
Mark McDowall
1043e7f43f Fix grabbing interactive search items 2025-12-25 20:48:19 -08:00
Mark McDowall
ce8a5d8a6b New: Manually import multiple items at the same time from Activity: Queue 2025-12-25 20:48:19 -08:00
Mark McDowall
ec44e1c513 Use react-query for manual import 2025-12-25 20:48:19 -08:00
Mark McDowall
8da611ea58 Add v5 manual import endpoints 2025-12-25 20:48:19 -08:00
Weblate
4c13a01c8e Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2025-12-25 20:48:08 -08:00
Mark McDowall
10c0e18a42 Use react-query for renaming 2025-12-22 13:35:47 -08:00
Mark McDowall
bc099f27cb Add v5 rename endpoints 2025-12-22 13:35:47 -08:00
Stevie Robinson
f94b3d8560 Fixed: Plexmatch special episode numbers
Closes #8270
2025-12-22 12:25:43 -08:00
Mark McDowall
6b479a5a10 Use react-query for episode and series history 2025-12-22 12:25:12 -08:00
Mark McDowall
5eb18fe274 New: Parse 'Vialle' release group
Closes #8261
2025-12-20 17:50:52 -08:00
Mark McDowall
7d59b466e7 Fixed: Parsing of some 1440p files
Closes #8259
2025-12-20 17:50:46 -08:00
Sonarr
21083d9041 Automated API Docs update
ignore-downstream
2025-12-20 17:50:32 -08:00
Bogdan
2453de80aa Fixed: Parsing URLs on some systems due to Locale 2025-12-20 17:31:55 -08:00
Bogdan
f722e51377 Fixed redirection to login page for forms authentication 2025-12-20 17:31:23 -08:00
Alexander
db95d7e42f Fixed: Format of timestamps for Discord notifications from some systems 2025-12-20 17:30:43 -08:00
Mark McDowall
9b7f2a5adf Fixed: Don't fail refresh for multiple series if one can't get updated information from Skyhook
Closes #8250
2025-12-20 17:30:05 -08:00
Bogdan
bb090a7c42 Bump FluentMigrator to 7.2 2025-12-20 17:29:44 -08:00
Mark McDowall
dec6f4b5f2 Use react-query for commands 2025-12-20 17:29:23 -08:00
Mark McDowall
dd12b9e076 Add v5 command endpoints 2025-12-20 17:29:23 -08:00
Bogdan
227db9ef39 New: Bump FFMpegCore to 5.4.0 and FFprobe to 8.0.1 2025-12-20 17:27:17 -08:00
Tyler Durr
5f9f8fddad Re-calibrating, not RE-calibrating 2025-12-20 17:27:05 -08:00
Weblate
cbade29c7a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Nyokals <Nicolas.bis@icloud.com>
Co-authored-by: Oleksandr Yurov <oyurov@icloud.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Simeon-byte <saimjen.duglas@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: foXaCe <foxace66@gmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
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/uk/
Translation: Servarr/Sonarr
2025-12-20 17:25:35 -08:00
Touchstone64
87892a1d0c Fixed: Prevent paths with multiple slashes causing backups to fail
Closes #8221
2025-12-12 17:20:54 -08:00
Touchstone64
4e52b3c93e Ensure tests that log warnings don't fail 2025-12-12 17:20:54 -08:00
Sonarr
aee6158ec5 Automated API Docs update
ignore-downstream
2025-12-12 17:20:39 -08:00
Mark McDowall
878f879c40 Convert app state to zustand stores 2025-12-12 17:08:02 -08:00
Mark McDowall
66efb904f2 Use react-query for translations 2025-12-12 17:08:02 -08:00
Mark McDowall
e73712bd8f Add v5 localization endpoints 2025-12-12 17:08:02 -08:00
Stevie Robinson
5542187f17 Fixed: Multiple XML declarations in kodi/xmbc episodes metadata
Closes #8242
2025-12-12 16:28:35 -08:00
Bogdan
de0f1d461d Fixed: Parsing quality and languages when manual importing item with multiple series error 2025-12-12 16:28:05 -08:00
Dror Levin
c749e2d1a3 Remove space before period in TVDB message 2025-12-12 16:27:49 -08:00
Mark McDowall
b612888ed4 Prevent missing media info from failing script import
Towards #8214
2025-12-12 16:27:28 -08:00
Mark McDowall
0ade5b4c22 Fixed: Cleanse logged RSS URLs 2025-12-12 16:27:20 -08:00
Mark McDowall
3c0fa4a3af Fix add series not sending the correct payload 2025-12-12 16:27:04 -08:00
Mark McDowall
0521a6c390 Use react-query for series 2025-12-12 16:27:04 -08:00
Mark McDowall
49db4a1d76 Add v5 season pass and series editor endpoints 2025-12-12 16:27:04 -08:00
Bogdan
1df3b116c1 Run tests using Postgres 18 2025-12-12 16:26:47 -08:00
Bogdan
5d655f98f2 New: Bump .NET to 10 2025-12-12 16:26:36 -08:00
Bogdan
5f846ab51e New: Bump minimum Postgres version to 15 for FluentMigrator 2025-12-12 16:26:20 -08:00
Bogdan
bef2986357 Update renamed generator id for PostgreSQL in FluentMigrator 2025-12-12 16:26:20 -08:00
Weblate
6f287cb1de Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Donato Battista <donato.donelio@gmail.com>
Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: HanaO00 <lwin24452@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: MakubeXoX <chf565633550@gmail.com>
Co-authored-by: Marco Ciotola <github@ciotola.dev>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: xveral <xaviveral@hotmail.com>
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/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
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/zh_TW/
Translation: Servarr/Sonarr
2025-12-12 16:21:09 -08:00
Mark McDowall
1178c98341 Use react-query for episodes 2025-11-28 19:14:41 -08:00
Mark McDowall
a97f2c016b Use react-query for episode selection 2025-11-28 19:14:41 -08:00
Sonarr
4959ef4321 Automated API Docs update
ignore-downstream
2025-11-28 19:14:35 -08:00
Mark McDowall
d252fa8ed6 Improve backup mutations 2025-11-28 18:57:57 -08:00
Mark McDowall
7d2e01d516 Use react-query for custom filters 2025-11-28 18:57:57 -08:00
Mark McDowall
91b242902d Use react-query for path input and file browser 2025-11-28 18:57:51 -08:00
Mark McDowall
2f119fefd1 Add additional v5 filesystem endpoints 2025-11-28 18:57:51 -08:00
Weblate
1ca148a7a9 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translation: Servarr/Sonarr
2025-11-28 18:57:44 -08:00
Sonarr
9db17883df Automated API Docs update
ignore-downstream
2025-11-26 18:58:59 -08:00
Mark McDowall
4c556eacc1 New: Show grabbed/blocklisted releases in Interactive Search
Closes #3955
2025-11-26 18:55:18 -08:00
Mark McDowall
e4298d0135 Add additional v5 history endpoints 2025-11-26 18:55:18 -08:00
Mark McDowall
8f95849e9b Use react-query for interactive search
New: Filter Interactive Search results by rejection reason
2025-11-26 18:55:18 -08:00
Mark McDowall
9b756df4bf Add v5 release endpoints
Towards #6960
2025-11-26 18:55:18 -08:00
Mark McDowall
8e537cb626 Don't send full paths to Skyhook for searches 2025-11-26 18:42:52 -08:00
Auggie
f76ae1cce9 Fixed: Respect delay profile when repacks and propers aren't preferred 2025-11-26 18:42:40 -08:00
Mark McDowall
7a5157df29 Use react-query for root folders
New: Add tooltip for empty root folders
Closes #8196
2025-11-26 18:42:19 -08:00
Mark McDowall
449caa12e3 Add v5 root folder endpoints 2025-11-26 18:42:19 -08:00
Bogdan
dd84bcd919 Bump Sentry to 5.16.2 2025-11-26 18:41:37 -08:00
Bogdan
26934a5d81 Simplify GitHubActionsTestLogger as dependency to test projects 2025-11-26 18:41:29 -08:00
Bogdan
84fbab6ab4 Simplify StyleCop.Analyzers as dependency for all projects 2025-11-26 18:41:29 -08:00
Mark McDowall
a047053fae Change Manage Episodes import button to Apply 2025-11-26 18:41:07 -08:00
Mark McDowall
6629ebc6e0 Fix manual import title when importing from Queue 2025-11-26 18:41:07 -08:00
Mark McDowall
44fc1e0e85 Use react-query for episode files 2025-11-26 18:41:07 -08:00
Mark McDowall
9cdf1bf721 Add v5 episode file endpoints 2025-11-26 18:41:07 -08:00
Mark McDowall
d2107e92f3 Episodes for queue item should not be null 2025-11-26 18:40:04 -08:00
Mark McDowall
7284898c7d New: Ignore extra files in theme-music and backdrop folders
Closes #8195
2025-11-26 18:40:04 -08:00
solidDoWant
d4f14246f1 New: Postgres Connection String option 2025-11-26 18:38:26 -08:00
Mark McDowall
ae18ad61bd Fixed: Testing qBittorrent after credentials change would always pass tests
Closes #8187
2025-11-26 18:37:41 -08:00
Mark McDowall
aac4760d30 Fix changing sort direction 2025-11-26 18:37:20 -08:00
Sonarr
21592f3d69 Automated API Docs update
ignore-downstream
2025-11-26 18:37:06 -08:00
Weblate
e1cfc5f550 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: HanaO00 <lwin24452@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Lars <lars.erik.heloe@gmail.com>
Co-authored-by: Lorenz <github.hatred879@passmail.net>
Co-authored-by: Marco Ciotola <github@ciotola.dev>
Co-authored-by: Oleksandr Yurov <oyurov@icloud.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: deadsynchronicity <github@lexkeller.ru>
Co-authored-by: elgumi <agg1000@msn.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: gzfy <2987255360@qq.com>
Co-authored-by: mugantronix <mugantronix@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
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/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
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/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_Hans/
Translation: Servarr/Sonarr
2025-11-26 18:36:59 -08:00
Mark McDowall
0809a72ce5 Use react-query for tags
New: Show error when tag cannot be created
Closes #7796
2025-11-15 10:35:05 -08:00
Mark McDowall
20ad1b4410 Add v5 tag endpoints 2025-11-15 10:35:05 -08:00
Mark McDowall
e89d58985a Fix task endpoint 2025-11-15 10:34:10 -08:00
Mark McDowall
4071278183 Use react-query for wanted missing and cutoff unmet 2025-11-15 10:12:55 -08:00
Mark McDowall
5a702dec12 Add v5 episode, missing and cutoff unmet endpoints 2025-11-15 10:12:55 -08:00
Stevie Robinson
317cdf1558 add TTL setting for pushover notifications 2025-11-15 10:12:45 -08:00
Weblate
f104268256 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Yuval Yaron <yuvalyaron2011@gmail.com>
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/he/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2025-11-15 10:12:17 -08:00
Mark McDowall
263f4839ab Use react-query for parse 2025-11-11 18:29:37 -08:00
Mark McDowall
19a65d672f Add v5 parse endpoint 2025-11-11 18:29:37 -08:00
Mark McDowall
2098303e25 New: Improve Plex auth for Cross-Origin-Opener-Policy support 2025-11-11 18:29:08 -08:00
Mark McDowall
ccb7f07c26 Use react-query for Calendar UI 2025-11-11 18:29:02 -08:00
Mark McDowall
6a3e1278a5 Add v5 calendar endpoints 2025-11-11 18:29:02 -08:00
Mark McDowall
5867cd5f47 New: Ignore torrents from Flood with matching Post-Import Tags
Closes #8147
2025-11-11 18:28:54 -08:00
Mark McDowall
c295e24fc6 Use react-query for backups 2025-11-11 18:28:47 -08:00
Mark McDowall
29170f17d2 Add v5 backup endpoints 2025-11-11 18:28:47 -08:00
Mark McDowall
3091f40ca8 Use react-query for tasks 2025-11-11 18:28:47 -08:00
Mark McDowall
565f967f7a Add v5 task endpoints 2025-11-11 18:28:47 -08:00
Mark McDowall
7960bb8c7d Fix episode status on wanted 2025-11-11 18:28:33 -08:00
Mark McDowall
b5967425f1 Convert select to SelectContext 2025-11-11 18:28:33 -08:00
Mark McDowall
910b85f37d New: Improve 'Select All' in Library Import
Closes #7909
2025-11-11 18:28:33 -08:00
Sonarr
08f0a5a960 Automated API Docs update
ignore-downstream
2025-11-11 18:28:24 -08:00
Weblate
0df0212b3a 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: Weblate <noreply@weblate.org>
Co-authored-by: filiphavlin <filip@havlin.cz>
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/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/tr/
Translation: Servarr/Sonarr
2025-11-11 18:28:18 -08:00
Mark McDowall
b64cc65579 Remove updates, log files from app state 2025-11-05 19:58:00 -08:00
Mark McDowall
871ae9555b Use react-query for disk space 2025-11-05 19:58:00 -08:00
Mark McDowall
2bc30037bd Add v5 diskspace endpoint 2025-11-05 19:58:00 -08:00
Mark McDowall
0552a81180 Use react-query for Health UI 2025-11-05 19:43:57 -08:00
Mark McDowall
9bc9d6d400 Add v5 health endpoints 2025-11-05 19:43:57 -08:00
Mark McDowall
beadc687ae Add reason to health check 2025-11-05 19:43:57 -08:00
Stevie Robinson
4f149257c0 Add finale type to on grab custom script notifications 2025-11-05 19:36:37 -08:00
Bogdan
255f1c37c3 Fallback to host sqlite3 on FreeBSD and Linux 2025-11-05 19:36:30 -08:00
Mark McDowall
0ff644e842 Queue hiding when switching pages 2025-11-05 19:36:20 -08:00
Mark McDowall
8718f28fbd Fixed: Loading queue or processing releases slow with many Pending Releases 2025-11-05 19:36:20 -08:00
Mark McDowall
b803635b6c Return maximum long value on overflow getting disk information
Closes #8130
2025-11-05 19:35:06 -08:00
Mark McDowall
dacd469627 New: Parse 'jap' and 'jpn' as Japanese
Closes #8121
2025-11-05 19:32:21 -08:00
Mark McDowall
49c52c2e1a Use react-query for System Status 2025-11-05 19:32:06 -08:00
Mark McDowall
fc0c26c2b3 Add v5 System endpoints 2025-11-05 19:32:06 -08:00
Harry Kim
4f7425086e New: Parse 'F1RST' releases as Korean 2025-11-05 19:32:00 -08:00
Andrew Ukkonen
a85419df15 New: Minimum Score for MAL Import List 2025-11-05 19:30:39 -08:00
Mark McDowall
7ef3b6bd0a Add private IPv6 networks 2025-11-05 19:29:54 -08:00
Mark McDowall
79f1d2e38c Set known networks to RFC 1918 ranges during startup 2025-11-05 19:29:53 -08:00
Sonarr
52ba6f4593 Automated API Docs update
ignore-downstream
2025-11-05 19:29:32 -08:00
Weblate
81c6f3ac75 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Alex <despedo@gmail.com>
Co-authored-by: ArLab1 <arnaud.laberge@hotmail.com>
Co-authored-by: DimitriDR <dimitridroeck@gmail.com>
Co-authored-by: Dream <seth.gecko.rr@gmail.com>
Co-authored-by: Gabriel Markowski <gmarkowski62@gmail.com>
Co-authored-by: Georgi Panov <darkfella91@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: He Zhu <zhuhe202@qq.com>
Co-authored-by: Hicabi Erdem <bilgi@hicabierdem.com>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Jurriaan Den Toonder <jur.den.toonder@gmail.com>
Co-authored-by: Lars <lars.erik.heloe@gmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Magyar <kochnorbert@icloud.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Richard de Souza Leite <rs9010482@gmail.com>
Co-authored-by: Surfoo <surfooo@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: josef <josef.holzapfel@proton.me>
Co-authored-by: keysuck <joshkkim@gmail.com>
Co-authored-by: liimee <git.taaa@fedora.email>
Co-authored-by: resi23 <x-resistant-x@gmx.de>
Co-authored-by: ube <ube@alienautopsy.net>
Co-authored-by: zichichi <sollami@gmail.com>
Co-authored-by: 康小广 <kenkangxgwe@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/de/
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/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pl/
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/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2025-11-05 19:28:58 -08:00
Audionut
550cf8d399 New: Option to specify timezone for formatting times in the UI 2025-10-21 21:07:34 -07:00
Mark McDowall
37dfad11f2 Remove v3 updates from UI 2025-10-21 21:06:42 -07:00
Mark McDowall
ff5e73273b Use react-query for Log Files 2025-10-21 21:06:42 -07:00
Mark McDowall
fce8e780cb Add v5 log files endpoints 2025-10-21 21:06:42 -07:00
Stevie Robinson
4fb89252b5 New: Parse multilang and multilanguage as multi
Closes #8119
2025-10-21 21:06:26 -07:00
Polgonite
5a3f41263a Fixed: qBittorrent /login API success check 2025-10-21 21:05:53 -07:00
Bogdan
52d7f67627 Switch to FluentMigrator.Runner.Core to avoid extranous platform runners 2025-10-21 21:05:31 -07:00
Stevie Robinson
a1db23353c Fix Queue v5 API resource mapping when no episodes present 2025-10-21 21:05:18 -07:00
Bogdan
79b56c3ff6 Pin System.Drawing.Common to 8.0.20 2025-10-21 21:04:18 -07:00
Bogdan
5568746ef8 Fixed: Validation for SSL certificate file existence 2025-10-21 21:04:13 -07:00
Stevie Robinson
813d7df643 Fixed: Notifications when episode is not available
Close #8105
2025-10-21 21:04:05 -07:00
Mark McDowall
b04b9f900f New: Add button to close side bar
Closes #7757
2025-10-21 21:03:08 -07:00
Mark McDowall
a45b077625 Use react-query for History UI 2025-10-21 21:02:33 -07:00
Mark McDowall
74ce132556 Add v5 History endpoints 2025-10-21 21:02:33 -07:00
Sonarr
ca364724cf Automated API Docs update
ignore-downstream
2025-10-21 21:02:24 -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
2125 changed files with 107511 additions and 88137 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-10.0",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "16",
"version": "20",
"nvmVersion": "latest"
}
},

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

@@ -0,0 +1,194 @@
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 NuGet registry source
shell: bash
if: ${{ startsWith(inputs.runtime, 'freebsd') }}
run:
dotnet nuget add source --configfile src/NuGet.Config --name gh-openur https://nuget.pkg.github.com/openur/index.json --username ${{ github.repository_owner }} --password ${{ github.token }} --store-password-in-clear-text
- 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:=net10.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@v8
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: net10.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 4
VERSION: 4.0.12
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, 18]
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

@@ -0,0 +1,26 @@
name: Close issues without labels
on:
issues:
types:
- opened
- reopened
jobs:
close-issue:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
sparse-checkout: |
.github
- name: Close issue if no labels found
if: join(github.event.issue.labels) == ''
run: |
gh issue comment ${{ github.event.issue.number }} --body ":wave: @${{ github.event.issue.user.login }}, this issue was closed automatically because it was created without following an issue template. Please update the issue following the correct template for this issue. Once updated please reply to this issue so we can review and re-open. In the future, use the [issue templates](https://github.com/${{ github.repository }}/issues/new/choose) instead of creating your own."
gh issue close ${{ github.event.issue.number }} --reason "not planned"
env:
GH_TOKEN: ${{ github.token }}

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/net10.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

@@ -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

@@ -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.39'
corejs: '3.42'
}
]
]

View File

@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { SelectProvider } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import React, { useCallback, useEffect, useState } from 'react';
import { setQueueOptions } from 'Activity/Queue/queueOptionsStore';
import { SelectProvider, useSelect } from 'App/Select/SelectContext';
import CommandNames from 'Commands/CommandNames';
import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
@@ -16,94 +16,77 @@ 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 { useCustomFiltersList } from 'Filters/useCustomFilters';
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';
import { SortDirection } from 'Helpers/Props/sortDirections';
import BlockListModel from 'typings/Blocklist';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import { TableOptionsChangePayload } from 'typings/Table';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import BlocklistFilterModal from './BlocklistFilterModal';
import {
setBlocklistOption,
setBlocklistSort,
useBlocklistOptions,
} from './blocklistOptionsStore';
import BlocklistRow from './BlocklistRow';
import useBlocklist, {
useFilters,
useRemoveBlocklistItems,
} from './useBlocklist';
function Blocklist() {
const requestCurrentPage = useCurrentPage();
function BlocklistContent() {
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 customFilters = useSelector(createCustomFiltersSelector('blocklist'));
const isClearingBlocklistExecuting = useSelector(
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST)
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useBlocklistOptions();
const filters = useFilters();
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
const customFilters = useCustomFiltersList('blocklist');
const executeCommand = useExecuteCommand();
const isClearingBlocklistExecuting = useCommandExecuting(
CommandNames.ClearBlocklist
);
const dispatch = useDispatch();
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
useState(false);
const [isConfirmClearModalOpen, setIsConfirmClearModalOpen] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const wasClearingBlocklistExecuting = usePrevious(
isClearingBlocklistExecuting
);
const {
allSelected,
allUnselected,
anySelected,
getSelectedIds,
selectAll,
unselectAll,
} = useSelect<BlockListModel>();
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
if (value) {
selectAll();
} else {
unselectAll();
}
},
[items, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
[selectAll, unselectAll]
);
const handleRemoveSelectedPress = useCallback(() => {
@@ -111,9 +94,9 @@ function Blocklist() {
}, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback(() => {
dispatch(removeBlocklistItems({ ids: selectedIds }));
removeBlocklistItems({ ids: getSelectedIds() });
setIsConfirmRemoveModalOpen(false);
}, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]);
}, [getSelectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]);
const handleConfirmRemoveModalClose = useCallback(() => {
setIsConfirmRemoveModalOpen(false);
@@ -124,66 +107,47 @@ function Blocklist() {
}, [setIsConfirmClearModalOpen]);
const handleClearBlocklistConfirmed = useCallback(() => {
dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST }));
executeCommand({ name: CommandNames.ClearBlocklist }, () => {
goToPage(1);
});
setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen, dispatch]);
}, [setIsConfirmClearModalOpen, goToPage, executeCommand]);
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 }));
(sortKey: string, sortDirection?: SortDirection) => {
setBlocklistSort({
sortKey,
sortDirection,
});
},
[dispatch]
[]
);
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,137 +155,129 @@ function Blocklist() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
useEffect(() => {
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
dispatch(gotoBlocklistPage({ page: 1 }));
}
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
}, [refetch]);
return (
<SelectProvider items={items}>
<PageContent title={translate('Blocklist')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={!selectedIds.length}
isSpinning={isRemoving}
onPress={handleRemoveSelectedPress}
/>
<PageContent title={translate('Blocklist')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={!anySelected}
isSpinning={isRemoving}
onPress={handleRemoveSelectedPress}
/>
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!items.length}
isSpinning={isClearingBlocklistExecuting}
onPress={handleClearBlocklistPress}
/>
</PageToolbarSection>
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!records.length}
isSpinning={isClearingBlocklistExecuting}
onPress={handleClearBlocklistPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={BlocklistFilterModal}
onFilterSelect={handleFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{isLoading && !isFetched ? <LoadingIndicator /> : null}
{!isLoading && !!error ? (
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
) : null}
{isFetched && !error && !records.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey === 'all'
? translate('NoBlocklistItems')
: translate('BlocklistFilterHasNoItems')}
</Alert>
) : null}
{isFetched && !error && !!records.length ? (
<div>
<Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={BlocklistFilterModal}
onFilterSelect={handleFilterSelect}
<TableBody>
{records.map((item) => {
return (
<BlocklistRow key={item.id} columns={columns} {...item} />
);
})}
</TableBody>
</Table>
<TablePager
page={page}
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onPageSelect={goToPage}
/>
</PageToolbarSection>
</PageToolbar>
</div>
) : null}
</PageContentBody>
<PageContentBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
<ConfirmModal
isOpen={isConfirmRemoveModalOpen}
kind={kinds.DANGER}
title={translate('RemoveSelected')}
message={translate('RemoveSelectedBlocklistMessageText')}
confirmLabel={translate('RemoveSelected')}
onConfirm={handleRemoveSelectedConfirmed}
onCancel={handleConfirmRemoveModalClose}
/>
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
) : null}
<ConfirmModal
isOpen={isConfirmClearModalOpen}
kind={kinds.DANGER}
title={translate('ClearBlocklist')}
message={translate('ClearBlocklistMessageText')}
confirmLabel={translate('Clear')}
onConfirm={handleClearBlocklistConfirmed}
onCancel={handleConfirmClearModalClose}
/>
</PageContent>
);
}
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey === 'all'
? translate('NoBlocklistItems')
: translate('BlocklistFilterHasNoItems')}
</Alert>
) : null}
function Blocklist() {
const { records } = useBlocklist();
{isPopulated && !error && !!items.length ? (
<div>
<Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
return (
<BlocklistRow
key={item.id}
isSelected={selectedState[item.id] || false}
columns={columns}
{...item}
onSelectedChange={handleSelectedChange}
/>
);
})}
</TableBody>
</Table>
<TablePager
page={page}
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
</div>
) : null}
</PageContentBody>
<ConfirmModal
isOpen={isConfirmRemoveModalOpen}
kind={kinds.DANGER}
title={translate('RemoveSelected')}
message={translate('RemoveSelectedBlocklistMessageText')}
confirmLabel={translate('RemoveSelected')}
onConfirm={handleRemoveSelectedConfirmed}
onCancel={handleConfirmRemoveModalClose}
/>
<ConfirmModal
isOpen={isConfirmClearModalOpen}
kind={kinds.DANGER}
title={translate('ClearBlocklist')}
message={translate('ClearBlocklistMessageText')}
confirmLabel={translate('Clear')}
onConfirm={handleClearBlocklistConfirmed}
onCancel={handleConfirmClearModalClose}
/>
</PageContent>
return (
<SelectProvider<BlockListModel> items={records}>
<BlocklistContent />
</SelectProvider>
);
}

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,5 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useSelect } from 'App/Select/SelectContext';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -11,40 +11,44 @@ import EpisodeLanguages from 'Episode/EpisodeLanguages';
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 { useSingleSeries } from 'Series/useSeries';
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 {
isSelected: boolean;
columns: Column[];
onSelectedChange: (options: SelectStateInputProps) => void;
}
function BlocklistRow(props: BlocklistRowProps) {
const {
id,
seriesId,
sourceTitle,
languages,
quality,
customFormats,
date,
protocol,
indexer,
message,
isSelected,
columns,
onSelectedChange,
} = props;
const series = useSeries(seriesId);
const dispatch = useDispatch();
function BlocklistRow({
id,
seriesId,
sourceTitle,
languages,
quality,
customFormats,
date,
protocol,
indexer,
message,
source,
columns,
}: BlocklistRowProps) {
const series = useSingleSeries(seriesId);
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const { toggleSelected, useIsSelected } = useSelect<Blocklist>();
const isSelected = useIsSelected(id);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
toggleSelected({ id, isSelected: value, shiftKey });
},
[toggleSelected]
);
const handleDetailsPress = useCallback(() => {
setIsDetailsModalOpen(true);
@@ -55,8 +59,8 @@ function BlocklistRow(props: BlocklistRowProps) {
}, [setIsDetailsModalOpen]);
const handleRemovePress = useCallback(() => {
dispatch(removeBlocklistItem({ id }));
}, [id, dispatch]);
removeBlocklistItem();
}, [removeBlocklistItem]);
if (!series) {
return null;
@@ -67,7 +71,7 @@ function BlocklistRow(props: BlocklistRowProps) {
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
onSelectedChange={handleSelectedChange}
/>
{columns.map((column) => {
@@ -139,6 +143,7 @@ function BlocklistRow(props: BlocklistRowProps) {
title={translate('RemoveFromBlocklist')}
name={icons.REMOVE}
kind={kinds.DANGER}
isSpinning={isRemoving}
onPress={handleRemovePress}
/>
</TableRowCell>
@@ -154,6 +159,7 @@ function BlocklistRow(props: BlocklistRowProps) {
protocol={protocol}
indexer={indexer}
message={message}
source={source}
onModalClose={handleDetailsModalClose}
/>
</TableRow>

View File

@@ -0,0 +1,72 @@
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import translate from 'Utilities/String/translate';
export type BlocklistOptions = PageableOptions;
const { useOptions, useOption, setOptions, setOption, setSort } =
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;
export const setBlocklistSort = setSort;

View File

@@ -0,0 +1,113 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { useCustomFiltersList } from 'Filters/useCustomFilters';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
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 = useCustomFiltersList('blocklist');
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

@@ -1,11 +1,10 @@
import React from 'react';
import { useSelector } from 'react-redux';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import Link from 'Components/Link/Link';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
import {
DownloadFailedHistory,
DownloadFolderImportedHistory,
@@ -18,6 +17,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';
@@ -32,9 +32,7 @@ interface HistoryDetailsProps {
function HistoryDetails(props: HistoryDetailsProps) {
const { eventType, sourceTitle, data, downloadId } = props;
const { shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const { shortDateFormat, timeFormat } = useUiSettingsValues();
if (eventType === 'grabbed') {
const {
@@ -50,6 +48,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
ageHours,
ageMinutes,
publishedDate,
size,
} = data as GrabbedHistoryData;
const downloadClientNameInfo = downloadClientName ?? downloadClient;
@@ -160,12 +159,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>
@@ -179,15 +185,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 (
@@ -220,12 +234,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 = '';
@@ -255,6 +277,13 @@ function HistoryDetails(props: HistoryDetailsProps) {
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
{size ? (
<DescriptionListItem
title={translate('FileSize')}
data={formatBytes(size)}
/>
) : null}
</DescriptionList>
);
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import Modal from 'Components/Modal/Modal';
@@ -9,6 +9,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import { HistoryData, HistoryEventType } from 'typings/History';
import translate from 'Utilities/String/translate';
import { useMarkAsFailed } from '../useHistory';
import HistoryDetails from './HistoryDetails';
import styles from './HistoryDetailsModal.css';
@@ -33,26 +34,32 @@ function getHeaderTitle(eventType: HistoryEventType) {
interface HistoryDetailsModalProps {
isOpen: boolean;
id: number;
eventType: HistoryEventType;
sourceTitle: string;
data: HistoryData;
downloadId?: string;
isMarkingAsFailed: boolean;
onMarkAsFailedPress: () => void;
onModalClose: () => void;
}
function HistoryDetailsModal(props: HistoryDetailsModalProps) {
const {
isOpen,
eventType,
sourceTitle,
data,
downloadId,
isMarkingAsFailed = false,
onMarkAsFailedPress,
onModalClose,
} = props;
const { isOpen, id, eventType, sourceTitle, data, downloadId, onModalClose } =
props;
const { markAsFailed, isMarkingAsFailed, markAsFailedError } =
useMarkAsFailed(id);
const wasMarkingAsFailed = useRef(isMarkingAsFailed);
const handleMarkAsFailedPress = useCallback(() => {
markAsFailed();
}, [markAsFailed]);
useEffect(() => {
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
onModalClose();
}
}, [wasMarkingAsFailed, isMarkingAsFailed, markAsFailedError, onModalClose]);
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
@@ -74,7 +81,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
className={styles.markAsFailedButton}
kind={kinds.DANGER}
isSpinning={isMarkingAsFailed}
onPress={onMarkAsFailedPress}
onPress={handleMarkAsFailedPress}
>
{translate('MarkAsFailed')}
</SpinnerButton>

View File

@@ -1,6 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import React, { useCallback, useEffect, useMemo } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
@@ -13,21 +11,10 @@ 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 useEpisodes from 'Episode/useEpisodes';
import { useCustomFiltersList } from 'Filters/useCustomFilters';
import { align, icons, kinds } from 'Helpers/Props';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
import {
clearHistory,
fetchHistory,
gotoHistoryPage,
setHistoryFilter,
setHistorySort,
setHistoryTableOption,
} from 'Store/Actions/historyActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import { SortDirection } from 'Helpers/Props/sortDirections';
import HistoryItem from 'typings/History';
import { TableOptionsChangePayload } from 'typings/Table';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
@@ -37,100 +24,86 @@ import {
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import HistoryFilterModal from './HistoryFilterModal';
import {
setHistoryOption,
setHistoryOptions,
setHistorySort,
useHistoryOptions,
} from './historyOptionsStore';
import HistoryRow from './HistoryRow';
import useHistory, { useFilters } from './useHistory';
function History() {
const requestCurrentPage = useCurrentPage();
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
records,
totalPages,
totalRecords,
} = useSelector((state: AppState) => state.history);
error,
isFetching,
isFetched,
isLoading,
page,
goToPage,
refetch,
} = useHistory();
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('history'));
const dispatch = useDispatch();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useHistoryOptions();
const isFetchingAny = isFetching || isEpisodesFetching;
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
const hasError = error || episodesError;
const episodeIds = useMemo(() => {
return selectUniqueIds<HistoryItem, number>(records, 'episodeId');
}, [records]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoHistoryPage,
});
isFetching: isEpisodesFetching,
isFetched: isEpisodesFetched,
error: episodesError,
} = useEpisodes({ episodeIds });
const filters = useFilters();
const customFilters = useCustomFiltersList('history');
const isFetchingAny = isLoading || isEpisodesFetching;
const isAllPopulated = isFetched && (isEpisodesFetched || !records.length);
const hasError = error || episodesError;
const handleFilterSelect = useCallback(
(selectedFilterKey: string) => {
dispatch(setHistoryFilter({ selectedFilterKey }));
(selectedFilterKey: string | number) => {
setHistoryOption('selectedFilterKey', selectedFilterKey);
},
[dispatch]
[]
);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setHistorySort({ sortKey }));
(sortKey: string, sortDirection?: SortDirection) => {
setHistorySort({
sortKey,
sortDirection,
});
},
[dispatch]
[]
);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setHistoryTableOption(payload));
setHistoryOptions(payload);
if (payload.pageSize) {
dispatch(gotoHistoryPage({ page: 1 }));
goToPage(1);
}
},
[dispatch]
[goToPage]
);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchHistory());
} else {
dispatch(gotoHistoryPage({ page: 1 }));
}
return () => {
dispatch(clearHistory());
dispatch(clearEpisodes());
dispatch(clearEpisodeFiles());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const episodeIds = selectUniqueIds<HistoryItem, number>(items, 'episodeId');
if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds }));
} else {
dispatch(clearEpisodes());
}
}, [items, dispatch]);
const handleRefreshPress = useCallback(() => {
goToPage(1);
refetch();
}, [goToPage, refetch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchHistory());
refetch();
};
registerPagePopulator(repopulate);
@@ -138,7 +111,7 @@ function History() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
}, [refetch]);
return (
<PageContent title={translate('History')}>
@@ -148,7 +121,7 @@ function History() {
label={translate('Refresh')}
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={handleFirstPagePress}
onPress={handleRefreshPress}
/>
</PageToolbarSection>
@@ -186,12 +159,12 @@ function History() {
// If history isPopulated and it's empty show no history found and don't
// wait for the episodes to populate because they are never coming.
isPopulated && !hasError && !items.length ? (
isFetched && !hasError && !records.length ? (
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
) : null
}
{isAllPopulated && !hasError && items.length ? (
{isAllPopulated && !hasError && records.length ? (
<div>
<Table
columns={columns}
@@ -202,7 +175,7 @@ function History() {
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
{records.map((item) => {
return (
<HistoryRow key={item.id} columns={columns} {...item} />
);
@@ -215,11 +188,7 @@ function History() {
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
onPageSelect={goToPage}
/>
</div>
) : null}

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 { setHistoryFilter } from 'Store/Actions/historyActions';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setHistoryOption } from './historyOptionsStore';
import useHistory, { FILTER_BUILDER } from './useHistory';
function createHistorySelector() {
return createSelector(
(state: AppState) => state.history.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.history.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
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 { records } = useHistory();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setHistoryFilter(payload));
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
setHistoryOption('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="history"
dispatchSetFilter={dispatchSetFilter}
/>
);

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import React, { useCallback, useState } from 'react';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -13,13 +12,11 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import useEpisode from 'Episode/useEpisode';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
import { useSingleSeries } from 'Series/useSeries';
import CustomFormat from 'typings/CustomFormat';
import { HistoryData, HistoryEventType } from 'typings/History';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
@@ -61,14 +58,10 @@ function HistoryRow(props: HistoryRowProps) {
date,
data,
downloadId,
isMarkingAsFailed = false,
markAsFailedError,
columns,
} = props;
const wasMarkingAsFailed = usePrevious(isMarkingAsFailed);
const dispatch = useDispatch();
const series = useSeries(seriesId);
const series = useSingleSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes');
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
@@ -81,23 +74,6 @@ function HistoryRow(props: HistoryRowProps) {
setIsDetailsModalOpen(false);
}, [setIsDetailsModalOpen]);
const handleMarkAsFailedPress = useCallback(() => {
dispatch(markAsFailed({ id }));
}, [id, dispatch]);
useEffect(() => {
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
setIsDetailsModalOpen(false);
dispatch(fetchHistory());
}
}, [
wasMarkingAsFailed,
isMarkingAsFailed,
markAsFailedError,
setIsDetailsModalOpen,
dispatch,
]);
if (!series || !episode) {
return null;
}
@@ -254,13 +230,12 @@ function HistoryRow(props: HistoryRowProps) {
})}
<HistoryDetailsModal
id={id}
isOpen={isDetailsModalOpen}
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
onMarkAsFailedPress={handleMarkAsFailedPress}
onModalClose={handleDetailsModalClose}
/>
</TableRow>

View File

@@ -0,0 +1,109 @@
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';
export type HistoryOptions = PageableOptions;
const { useOptions, useOption, setOptions, setOption, setSort } =
createOptionsStore<HistoryOptions>('history_options', () => {
return {
pageSize: 20,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'eventType',
label: '',
columnLabel: () => translate('EventType'),
isVisible: true,
isModifiable: false,
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true,
},
{
name: 'episode',
label: () => translate('Episode'),
isVisible: true,
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
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: 'downloadClient',
label: () => translate('DownloadClient'),
isVisible: false,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isVisible: false,
},
{
name: 'releaseGroup',
label: () => translate('ReleaseGroup'),
isVisible: false,
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: false,
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isVisible: false,
},
{
name: 'details',
label: '',
columnLabel: () => translate('Details'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useHistoryOptions = useOptions;
export const setHistoryOptions = setOptions;
export const useHistoryOption = useOption;
export const setHistoryOption = setOption;
export const setHistorySort = setSort;

View File

@@ -0,0 +1,20 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import History from 'typings/History';
const DEFAULT_HISTORY: History[] = [];
const useEpisodeHistory = (episodeId: number) => {
const { data, ...result } = useApiQuery<History[]>({
path: '/history/episode',
queryParams: {
episodeId,
},
});
return {
data: data ?? DEFAULT_HISTORY,
...result,
};
};
export default useEpisodeHistory;

View File

@@ -0,0 +1,192 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { useCustomFiltersList } from 'Filters/useCustomFilters';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
import History from 'typings/History';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { useHistoryOptions } from './historyOptionsStore';
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
{
key: 'grabbed',
label: () => translate('Grabbed'),
filters: [
{
key: 'eventType',
value: '1',
type: 'equal',
},
],
},
{
key: 'imported',
label: () => translate('Imported'),
filters: [
{
key: 'eventType',
value: '3',
type: 'equal',
},
],
},
{
key: 'failed',
label: () => translate('Failed'),
filters: [
{
key: 'eventType',
value: '4',
type: 'equal',
},
],
},
{
key: 'deleted',
label: () => translate('Deleted'),
filters: [
{
key: 'eventType',
value: '5',
type: 'equal',
},
],
},
{
key: 'renamed',
label: () => translate('Renamed'),
filters: [
{
key: 'eventType',
value: '6',
type: 'equal',
},
],
},
{
key: 'ignored',
label: () => translate('Ignored'),
filters: [
{
key: 'eventType',
value: '7',
type: 'equal',
},
],
},
];
export const FILTER_BUILDER: FilterBuilderProp<History>[] = [
{
name: 'eventType',
label: () => translate('EventType'),
type: 'equal',
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE,
},
{
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,
},
];
type HistoryType = 'episode' | 'series';
const MARK_AS_FAILED_QUERY_KEYS: Record<HistoryType, string> = {
episode: '/history/episode',
series: '/history/series',
} as const;
const useHistory = () => {
const { page, goToPage } = usePage('history');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useHistoryOptions();
const customFilters = useCustomFiltersList('history');
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<History>({
path: '/history',
page,
pageSize,
filters,
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
const handleGoToPage = useCallback(
(page: number) => {
goToPage(page);
},
[goToPage]
);
return {
...query,
goToPage: handleGoToPage,
page,
refetch,
};
};
export default useHistory;
export const useFilters = () => {
return FILTERS;
};
export const useMarkAsFailed = (id: number, type?: HistoryType) => {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/history/failed/${id}`,
method: 'POST',
mutationOptions: {
onMutate: () => {
setError(null);
},
onSuccess: () => {
const queryKey = type ? MARK_AS_FAILED_QUERY_KEYS[type] : '/history';
queryClient.invalidateQueries({ queryKey: [queryKey] });
},
onError: () => {
setError('Error marking history item as failed');
},
},
});
return {
markAsFailed: mutate,
isMarkingAsFailed: isPending,
markAsFailedError: error,
};
};

View File

@@ -0,0 +1,24 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import History from 'typings/History';
const DEFAULT_HISTORY: History[] = [];
const useSeriesHistory = (
seriesId: number,
seasonNumber: number | undefined
) => {
const { data, ...result } = useApiQuery<History[]>({
path: '/history/series',
queryParams: {
seriesId,
seasonNumber,
},
});
return {
data: data ?? DEFAULT_HISTORY,
...result,
};
};
export default useSeriesHistory;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,9 +6,9 @@ import React, {
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import { SelectProvider, useSelect } from 'App/Select/SelectContext';
import CommandNames from 'Commands/CommandNames';
import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
@@ -22,28 +22,13 @@ 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 useEpisodes from 'Episode/useEpisodes';
import { useCustomFiltersList } from 'Filters/useCustomFilters';
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 { SortDirection } from 'Helpers/Props/sortDirections';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import QueueItem from 'typings/Queue';
import QueueModel from 'typings/Queue';
import { TableOptionsChangePayload } from 'typings/Table';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import {
@@ -51,192 +36,185 @@ import {
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import QueueFilterModal from './QueueFilterModal';
import QueueOptions from './QueueOptions';
import {
setQueueOption,
setQueueOptions,
setQueueSort,
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();
function QueueContent() {
const executeCommand = useExecuteCommand();
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,
isLoading,
page,
goToPage,
refetch,
} = useQueue();
const { count } = useSelector(createQueueStatusSelector());
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('queue'));
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useQueueOptions();
const isRefreshMonitoredDownloadsExecuting = useSelector(
createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS)
const filters = useFilters();
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
const { count } = useQueueStatus();
const episodeIds = useMemo(() => {
return selectUniqueIds<QueueModel, number>(records, 'episodeIds');
}, [records]);
const {
isFetching: isEpisodesFetching,
isFetched: isEpisodesFetched,
error: episodesError,
} = useEpisodes({ episodeIds });
const customFilters = useCustomFiltersList('queue');
const isRefreshMonitoredDownloadsExecuting = useCommandExecuting(
CommandNames.RefreshMonitoredDownloads
);
const shouldBlockRefresh = useRef(false);
const currentQueue = useRef<ReactElement | null>(null);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const { allSelected, allUnselected, selectAll, unselectAll, useSelectedIds } =
useSelect<QueueModel>();
const selectedIds = useSelectedIds();
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 [isInteractiveImportDownloadIds, setIsInteractiveImportDownloadIds] =
useState<string[]>(() => []);
const isRefreshing =
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
// Use isLoading over isFetched to avoid losing the table UI when switching pages
const isAllPopulated =
isPopulated &&
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
!isLoading &&
(isEpisodesFetched ||
!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 });
if (value) {
selectAll();
} else {
unselectAll();
}
},
[items, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
[selectAll, unselectAll]
);
const handleRefreshPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.REFRESH_MONITORED_DOWNLOADS,
})
);
}, [dispatch]);
executeCommand({
name: CommandNames.RefreshMonitoredDownloads,
});
}, [executeCommand]);
const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => {
shouldBlockRefresh.current = isOpen;
}, []);
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, removeQueueItems]);
const handleConfirmRemoveModalClose = useCallback(() => {
shouldBlockRefresh.current = false;
setIsConfirmRemoveModalOpen(false);
}, [setIsConfirmRemoveModalOpen]);
}, []);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoQueuePage,
});
const handleImportSelectedPress = useCallback(() => {
shouldBlockRefresh.current = true;
setIsInteractiveImportDownloadIds(
selectedIds
.map((id) => {
const item = records.find((i) => i.id === id);
return item?.downloadId;
})
.filter((id): id is string => !!id)
);
}, [records, selectedIds]);
const handleImportSelectedModalClose = useCallback(() => {
shouldBlockRefresh.current = false;
setIsInteractiveImportDownloadIds([]);
}, []);
const handleFilterSelect = useCallback(
(selectedFilterKey: string) => {
dispatch(setQueueFilter({ selectedFilterKey }));
(selectedFilterKey: string | number) => {
setQueueOption('selectedFilterKey', selectedFilterKey);
},
[dispatch]
[]
);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setQueueSort({ sortKey }));
(sortKey: string, sortDirection?: SortDirection) => {
setQueueSort({
sortKey,
sortDirection,
});
},
[dispatch]
[]
);
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'
);
if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds }));
} else {
dispatch(clearEpisodes());
}
}, [items, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchQueue());
refetch();
};
registerPagePopulator(repopulate);
@@ -244,7 +222,7 @@ function Queue() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
}, [refetch]);
if (!shouldBlockRefresh.current) {
currentQueue.current = (
@@ -255,7 +233,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 +241,7 @@ function Queue() {
</Alert>
) : null}
{isAllPopulated && !hasError && !!items.length ? (
{isAllPopulated && !hasError && !!records.length ? (
<div>
<Table
selectAll={true}
@@ -273,21 +251,17 @@ function Queue() {
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
{records.map((item) => {
return (
<QueueRow
key={item.id}
episodeId={item.episodeId}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={handleSelectedChange}
onQueueRowModalOpenOrClose={
handleQueueRowModalOpenOrClose
}
@@ -302,11 +276,7 @@ function Queue() {
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
onPageSelect={goToPage}
/>
</div>
) : null}
@@ -342,6 +312,15 @@ function Queue() {
isSpinning={isRemoving}
onPress={handleRemoveSelectedPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('ImportSelected')}
iconName={icons.INTERACTIVE}
isDisabled={disableSelectedActions}
onPress={handleImportSelectedPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
@@ -349,7 +328,6 @@ function Queue() {
columns={columns}
pageSize={pageSize}
maxPageSize={200}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton
@@ -376,24 +354,24 @@ function Queue() {
selectedCount={selectedCount}
canChangeCategory={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
selectedIds.every((id: number) => {
const item = records.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory);
})
}
canIgnore={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
selectedIds.every((id: number) => {
const item = records.find((i) => i.id === id);
return !!(item && item.seriesId && item.episodeId);
})
}
isPending={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
selectedIds.every((id: number) => {
const item = records.find((i) => i.id === id);
if (!item) {
return false;
@@ -408,8 +386,25 @@ function Queue() {
onRemovePress={handleRemoveSelectedConfirmed}
onModalClose={handleConfirmRemoveModalClose}
/>
<InteractiveImportModal
isOpen={isInteractiveImportDownloadIds.length > 0}
downloadIds={isInteractiveImportDownloadIds}
title={translate('InteractiveImportMultipleQueueItems')}
onModalClose={handleImportSelectedModalClose}
/>
</PageContent>
);
}
function Queue() {
const { records } = useQueue();
return (
<SelectProvider<QueueModel> items={records}>
<QueueContent />
</SelectProvider>
);
}
export default Queue;

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,24 @@
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 { SetFilter } from 'Components/Filter/Filter';
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 { records } = useQueue();
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setQueueFilter(payload));
},
[dispatch]
);
const dispatchSetFilter = useCallback(({ selectedFilterKey }: SetFilter) => {
setQueueOption('selectedFilterKey', selectedFilterKey);
}, []);
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,48 +0,0 @@
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 { inputTypes } from 'Helpers/Props';
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
import { CheckInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
function QueueOptions() {
const dispatch = useDispatch();
const { includeUnknownSeriesItems } = useSelector(
(state: AppState) => state.queue.options
);
const handleOptionChange = useCallback(
({ name, value }: CheckInputChanged) => {
dispatch(
setQueueOption({
[name]: value,
})
);
if (name === 'includeUnknownSeriesItems') {
dispatch(gotoQueuePage({ page: 1 }));
}
},
[dispatch]
);
return (
<FormGroup>
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')}
onChange={handleOptionChange}
/>
</FormGroup>
);
}
export default QueueOptions;

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import { Error } from 'App/State/AppSectionState';
import { useSelect } from 'App/Select/SelectContext';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
@@ -15,20 +14,17 @@ 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 { useEpisodesWithIds } from 'Episode/useEpisode';
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 { useSingleSeries } from 'Series/useSeries';
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
import CustomFormat from 'typings/CustomFormat';
import { SelectStateInputProps } from 'typings/props';
import {
import Queue, {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,
StatusMessage,
@@ -36,16 +32,19 @@ 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;
downloadId?: string;
episodeIds: number[];
downloadId: string;
title: string;
status: string;
trackedDownloadStatus?: QueueTrackedDownloadStatus;
@@ -58,20 +57,18 @@ 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[];
onSelectedChange: (options: SelectStateInputProps) => void;
onQueueRowModalOpenOrClose: (isOpen: boolean) => void;
}
@@ -79,7 +76,7 @@ function QueueRow(props: QueueRowProps) {
const {
id,
seriesId,
episodeId,
episodeIds,
downloadId,
title,
status,
@@ -97,25 +94,24 @@ function QueueRow(props: QueueRowProps) {
downloadClient,
downloadClientHasPostImportCategory,
estimatedCompletionTime,
isFullSeason,
seasonNumbers,
added,
timeleft,
timeLeft,
size,
sizeleft,
isGrabbing = false,
grabError,
isRemoving = false,
isSelected,
sizeLeft,
columns,
onSelectedChange,
onQueueRowModalOpenOrClose,
} = props;
const dispatch = useDispatch();
const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes');
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const series = useSingleSeries(seriesId);
const episodes = useEpisodesWithIds(episodeIds);
const { showRelativeDates, shortDateFormat, timeFormat } =
useUiSettingsValues();
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
const { toggleSelected, useIsSelected } = useSelect<Queue>();
const isSelected = useIsSelected(id);
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
useState(false);
@@ -124,8 +120,8 @@ function QueueRow(props: QueueRowProps) {
useState(false);
const handleGrabPress = useCallback(() => {
dispatch(grabQueueItem({ id }));
}, [id, dispatch]);
grabQueueItem();
}, [grabQueueItem]);
const handleInteractiveImportPress = useCallback(() => {
onQueueRowModalOpenOrClose(true);
@@ -142,21 +138,33 @@ 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 handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
toggleSelected({
id,
isSelected: value,
shiftKey,
});
},
[toggleSelected]
);
const progress = 100 - (sizeLeft / size) * 100;
const showInteractiveImport =
status === 'completed' && trackedDownloadStatus === 'warning';
const isPending =
@@ -167,7 +175,7 @@ function QueueRow(props: QueueRowProps) {
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
onSelectedChange={handleSelectedChange}
/>
{columns.map((column) => {
@@ -209,23 +217,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 +230,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 +332,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}
@@ -390,8 +397,8 @@ function QueueRow(props: QueueRowProps) {
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
downloadId={downloadId}
modalTitle={title}
downloadIds={[downloadId]}
title={title}
onModalClose={handleInteractiveImportModalClose}
/>

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,36 +1,22 @@
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 translate from 'Utilities/String/translate';
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} />
<PageSidebarStatus
aria-label={
count === 1
? translate('QueueItem')
: translate('QueueItems', { count })
}
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,33 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export default function useQueueStatus() {
const { data } = useApiQuery<QueueStatus>({
path: '/queue/status',
});
if (!data) {
return {
count: 0,
errors: false,
warnings: false,
};
}
const { errors, warnings, unknownErrors, unknownWarnings, totalCount } = data;
return {
count: totalCount,
errors: errors || unknownErrors,
warnings: warnings || unknownWarnings,
};
}

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,159 @@
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 {
removalOptions: QueueRemovalOptions;
}
const { useOptions, useOption, setOptions, setOption, setSort } =
createOptionsStore<QueueOptions>('queue_options', () => {
return {
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;
export const setQueueSort = setSort;

View File

@@ -0,0 +1,209 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { useCustomFiltersList } from 'Filters/useCustomFilters';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
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: [],
},
{
key: 'excludeUnknownSeriesItems',
label: () => translate('ExcludeUnknownSeriesItems'),
filters: [
{
key: 'includeUnknownSeriesItems',
value: [false],
type: 'equal',
},
],
},
];
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,
},
{
name: 'includeUnknownSeriesItems',
label: () => translate('UnknownSeriesItems'),
type: 'equal',
valueType: filterBuilderValueTypes.BOOL,
},
];
const useQueue = () => {
const { page, goToPage } = usePage('queue');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useQueueOptions();
const customFilters = useCustomFiltersList('queue');
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<Queue>({
path: '/queue',
page,
pageSize,
filters,
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,140 @@
import React, { useCallback, useEffect, useState } 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 useDebounce from 'Helpers/Hooks/useDebounce';
import useQueryParams from 'Helpers/Hooks/useQueryParams';
import { icons, kinds } from 'Helpers/Props';
import { useHasSeries } from 'Series/useSeries';
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 hasSeries = useHasSeries();
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 && !hasSeries ? (
<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,302 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import AddSeries from 'AddSeries/AddSeries';
import {
AddSeriesOptions,
setAddSeriesOption,
useAddSeriesOptions,
} from 'AddSeries/addSeriesOptionsStore';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
import { useAppDimension } from 'App/appStore';
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 { getValidationFailures } from 'Helpers/Hooks/useApiMutation';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesType } from 'Series/Series';
import SeriesPoster from 'Series/SeriesPoster';
import selectSettings from 'Store/Selectors/selectSettings';
import { useIsWindows } from 'System/Status/useSystemStatus';
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 = useAppDimension('isSmallScreen');
const isWindows = useIsWindows();
const { isAdding, addError, addSeries } = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return {
...selectSettings(options, {}),
...getValidationFailures(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,
addOptions: {
monitor: monitor.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
},
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.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}
title={title}
/>
</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

@@ -97,6 +97,12 @@
pointer-events: all;
}
.excludedIcon {
margin-left: 10px;
color: var(--dangerColor);
pointer-events: all;
}
.overview {
margin-top: 20px;
}

View File

@@ -3,6 +3,7 @@
interface CssExports {
'alreadyExistsIcon': string;
'content': string;
'excludedIcon': string;
'genres': string;
'icons': string;
'network': string;

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,192 @@
import React, { useCallback, useState } from 'react';
import AddSeries from 'AddSeries/AddSeries';
import { useAppDimension } from 'App/appStore';
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 useExistingSeries from 'Series/useExistingSeries';
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,
isExcluded,
} = series;
const isExistingSeries = useExistingSeries(tvdbId);
const isSmallScreen = useAppDimension('isSmallScreen');
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}
title={title}
/>
)}
<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}
{isExcluded ? (
<Icon
className={styles.excludedIcon}
name={icons.DANGER}
size={36}
title={translate('SeriesInImportListExclusions')}
/>
) : 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,62 @@
import { useQueryClient } from '@tanstack/react-query';
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';
interface AddSeriesPayload
extends AddSeries,
Omit<
AddSeriesOptions,
'monitor' | 'searchForMissingEpisodes' | 'searchForCutoffUnmetEpisodes'
> {}
const DEFAULT_SERIES: AddSeries[] = [];
export const useLookupSeries = (query: string, isEnabled = true) => {
const result = useApiQuery<AddSeries[]>({
path: '/series/lookup',
queryParams: {
term: query,
},
queryOptions: {
enabled: isEnabled && !!query,
// Disable refetch on window focus to prevent refetching when the user switch tabs
refetchOnWindowFocus: false,
},
});
return {
...result,
data: result.data ?? DEFAULT_SERIES,
};
};
export const useAddSeries = () => {
const queryClient = useQueryClient();
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
{
path: '/series',
method: 'POST',
mutationOptions: {
onSuccess: (newSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return [newSeries];
}
return [...oldSeries, newSeries];
});
},
},
}
);
return {
isAdding: isPending,
addError: error,
addSeries: mutate,
};
};

View File

@@ -0,0 +1,8 @@
import Series from 'Series/Series';
interface AddSeries extends Series {
folder: string;
isExcluded: boolean;
}
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,119 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useParams } from 'react-router';
import {
setAddSeriesOption,
useAddSeriesOption,
} from 'AddSeries/addSeriesOptionsStore';
import { SelectProvider } from 'App/Select/SelectContext';
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 useRootFolders, { useRootFolder } from 'RootFolder/useRootFolders';
import { useQualityProfilesData } from 'Settings/Profiles/Quality/useQualityProfiles';
import translate from 'Utilities/String/translate';
import ImportSeriesFooter from './ImportSeriesFooter';
import { clearImportSeries } from './importSeriesStore';
import ImportSeriesTable from './ImportSeriesTable';
function ImportSeries() {
const { rootFolderId: rootFolderIdString } = useParams<{
rootFolderId: string;
}>();
const rootFolderId = parseInt(rootFolderIdString);
const {
isFetching: rootFoldersFetching,
isFetched: rootFoldersFetched,
error: rootFoldersError,
data: rootFolders,
} = useRootFolders();
useRootFolder(rootFolderId, false);
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 = useQualityProfilesData();
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
const scrollerRef = useRef<HTMLDivElement>(null);
const items = useMemo(() => {
return unmappedFolders.map((unmappedFolder) => {
return {
...unmappedFolder,
id: unmappedFolder.name,
};
});
}, [unmappedFolders]);
useEffect(() => {
return () => {
clearImportSeries();
};
}, [rootFolderId]);
useEffect(() => {
if (
!defaultQualityProfileId ||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
) {
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
}
}, [defaultQualityProfileId, qualityProfiles]);
return (
<SelectProvider items={items}>
<PageContent title={translate('ImportSeries')}>
<PageContentBody ref={scrollerRef}>
{rootFoldersFetching && !rootFoldersFetched ? (
<LoadingIndicator />
) : null}
{!rootFoldersFetching && !!rootFoldersError ? (
<Alert kind={kinds.DANGER}>
{translate('RootFoldersLoadError')}
</Alert>
) : null}
{!rootFoldersError &&
!rootFoldersFetching &&
rootFoldersFetched &&
!unmappedFolders.length ? (
<Alert kind={kinds.INFO}>
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
</Alert>
) : null}
{!rootFoldersError &&
rootFoldersFetched &&
!!unmappedFolders.length &&
scrollerRef.current ? (
<ImportSeriesTable items={items} scrollerRef={scrollerRef} />
) : null}
</PageContentBody>
{!rootFoldersError && rootFoldersFetched && !!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,305 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
AddSeriesOptions,
setAddSeriesOption,
useAddSeriesOptions,
} from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/Select/SelectContext';
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 { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import {
ImportSeriesItem,
startProcessing,
stopProcessing,
updateImportSeriesItem,
useImportSeriesItems,
useLookupQueueHasItems,
} from './importSeriesStore';
import { useImportSeries } from './useImportSeries';
import styles from './ImportSeriesFooter.css';
type MixedType = 'mixed';
function ImportSeriesFooter() {
const {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder,
} = useAddSeriesOptions();
const items = useImportSeriesItems();
const isLookingUpSeries = useLookupQueueHasItems();
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 { selectedCount, getSelectedIds } = useSelect<ImportSeriesItem>();
const { importSeries, isImporting, importError } = useImportSeries();
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.hasSearched) {
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);
getSelectedIds().forEach((id) => {
updateImportSeriesItem({
id,
[name]: value,
});
});
},
[getSelectedIds]
);
const handleLookupPress = useCallback(() => {
startProcessing();
}, []);
const handleCancelLookupPress = useCallback(() => {
stopProcessing();
}, []);
const handleImportPress = useCallback(() => {
importSeries(getSelectedIds());
}, [importSeries, getSelectedIds]);
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]);
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.statusBody) ? (
importError.statusBody.map((error, index) => {
return <li key={index}>{error.errorMessage}</li>;
})
) : (
<li>{JSON.stringify(importError.statusBody)}</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,125 @@
import React, { useCallback, useEffect } from 'react';
import { useSelect } from 'App/Select/SelectContext';
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 useExistingSeries from 'Series/useExistingSeries';
import { InputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import {
ImportSeriesItem,
UnamppedFolderItem,
updateImportSeriesItem,
useImportSeriesItem,
} from './importSeriesStore';
import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries';
import styles from './ImportSeriesRow.css';
interface ImportSeriesRowProps {
unmappedFolder: UnamppedFolderItem;
}
function ImportSeriesRow({ unmappedFolder }: ImportSeriesRowProps) {
const id = unmappedFolder.id;
const item = useImportSeriesItem(unmappedFolder.id);
const {
relativePath,
monitor,
qualityProfileId,
seasonFolder,
seriesType,
selectedSeries,
} = item ?? {};
const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId);
const { getIsSelected, toggleSelected, toggleDisabled } =
useSelect<ImportSeriesItem>();
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
updateImportSeriesItem({ id, [name]: value });
},
[id]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey }: SelectStateInputProps<string>) => {
toggleSelected({
id,
isSelected: value,
shiftKey,
});
},
[toggleSelected]
);
useEffect(() => {
toggleDisabled(id, !selectedSeries || isExistingSeries);
}, [id, selectedSeries, isExistingSeries, toggleDisabled]);
useEffect(() => {
toggleSelected({ id, isSelected: !!selectedSeries, shiftKey: false });
}, [id, selectedSeries, toggleSelected]);
return (
<>
<VirtualTableSelectCell<string>
inputClassName={styles.selectInput}
id={id}
isSelected={getIsSelected(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,97 @@
import React, { RefObject, useCallback, useRef } from 'react';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useAppDimension } from 'App/appStore';
import { useSelect } from 'App/Select/SelectContext';
import VirtualTable from 'Components/Table/VirtualTable';
import { CheckInputChanged } from 'typings/inputs';
import ImportSeriesHeader from './ImportSeriesHeader';
import ImportSeriesRow from './ImportSeriesRow';
import {
UnamppedFolderItem,
useEnsureImportSeriesItems,
} from './importSeriesStore';
import styles from './ImportSeriesTable.css';
const ROW_HEIGHT = 52;
interface RowItemData {
items: UnamppedFolderItem[];
}
interface ImportSeriesTableProps {
items: UnamppedFolderItem[];
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} unmappedFolder={item} />
</div>
);
}
function ImportSeriesTable({ items, scrollerRef }: ImportSeriesTableProps) {
const isSmallScreen = useAppDimension('isSmallScreen');
const { allSelected, allUnselected, selectAll, unselectAll, useHasItems } =
useSelect();
const listRef = useRef<FixedSizeList<RowItemData>>(null);
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
if (value) {
selectAll();
} else {
unselectAll();
}
},
[selectAll, unselectAll]
);
const hasSelectItems = useHasItems();
useEnsureImportSeriesItems(items);
if (!items.length || !hasSelectItems) {
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,35 @@
import PropTypes from 'prop-types';
import React, { useCallback } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import useExistingSeries from 'Series/useExistingSeries';
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 = useExistingSeries(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 +52,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,248 @@
import {
autoUpdate,
flip,
FloatingPortal,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import React, { useCallback, useEffect, useState } from 'react';
import { useLookupSeries } from 'AddSeries/AddNewSeries/useAddSeries';
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 useDebounce from 'Helpers/Hooks/useDebounce';
import { icons, kinds } from 'Helpers/Props';
import useExistingSeries from 'Series/useExistingSeries';
import { InputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import {
addToLookupQueue,
removeFromLookupQueue,
updateImportSeriesItem,
useImportSeriesItem,
useIsCurrentedItemQueued,
useIsCurrentLookupQueueItem,
} from '../importSeriesStore';
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 importSeriesItem = useImportSeriesItem(id);
const { selectedSeries, name } = importSeriesItem ?? {};
const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId);
const [term, setTerm] = useState(name);
const [isOpen, setIsOpen] = useState(false);
const query = useDebounce(term, term ? 300 : 0);
const isCurrentLookupQueueItem = useIsCurrentLookupQueueItem(id);
const isQueued = useIsCurrentedItemQueued(id);
const { isFetching, isFetched, error, data, refetch } = useLookupSeries(
query,
isCurrentLookupQueueItem
);
const errorMessage = getErrorMessage(error);
const isLookingUpSeries = isFetching || isQueued;
const handlePress = useCallback(() => {
setIsOpen((prevIsOpen) => !prevIsOpen);
}, []);
const handleSearchInputChange = useCallback(
({ value }: InputChanged<string>) => {
setTerm(value);
addToLookupQueue(id);
},
[id]
);
const handleRefreshPress = useCallback(() => {
refetch();
}, [refetch]);
const handleSeriesSelect = useCallback(
(tvdbId: number) => {
setIsOpen(false);
const selectedSeries = data.find((item) => item.tvdbId === tvdbId)!;
updateImportSeriesItem({
id,
selectedSeries,
});
if (selectedSeries.seriesType !== 'standard') {
onInputChange({
name: 'seriesType',
value: selectedSeries.seriesType,
});
}
},
[id, data, onInputChange]
);
useEffect(() => {
if (isFetched) {
updateImportSeriesItem({
id,
hasSearched: isFetched,
selectedSeries: data[0],
});
removeFromLookupQueue(id);
}
}, [id, isFetched, data]);
useEffect(() => {
setTerm(name);
}, [name]);
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 && !isFetched ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isFetched && selectedSeries && isExistingSeries ? (
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
) : null}
{isFetched && selectedSeries ? (
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
network={selectedSeries.network}
isExistingSeries={isExistingSeries}
/>
) : null}
{isFetched && !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}>
{data.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

@@ -0,0 +1,175 @@
import { useEffect } from 'react';
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import { UnmappedFolder } from 'RootFolder/useRootFolders';
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
export interface UnamppedFolderItem extends UnmappedFolder {
id: string;
}
export interface ImportSeriesItem {
id: string;
monitor: SeriesMonitor;
path: string;
qualityProfileId: number;
relativePath: string;
seasonFolder: boolean;
selectedSeries?: Series;
seriesType: SeriesType;
name: string;
hasSearched: boolean;
}
interface ImportSeriesState {
items: Record<string, ImportSeriesItem>;
lookupQueue: string[];
isProcessing: boolean;
}
const defaultState: ImportSeriesState = {
items: {},
lookupQueue: [],
isProcessing: false,
};
const importSeriesStore = create<ImportSeriesState>()(() => defaultState);
export const useEnsureImportSeriesItems = (
unmappedFolders: UnamppedFolderItem[]
) => {
const { monitor, qualityProfileId, seriesType, seasonFolder } =
useAddSeriesOptions();
useEffect(() => {
unmappedFolders.forEach((unmappedFolder) => {
const existingItem =
importSeriesStore.getState().items[unmappedFolder.id];
if (existingItem) {
return;
}
const newItem: ImportSeriesItem = {
...unmappedFolder,
monitor,
qualityProfileId,
seriesType,
seasonFolder,
hasSearched: false,
};
importSeriesStore.setState((state) => ({
items: {
...state.items,
[unmappedFolder.id]: newItem,
},
}));
});
}, [unmappedFolders, monitor, qualityProfileId, seriesType, seasonFolder]);
};
export const updateImportSeriesItem = (
itemData: Partial<ImportSeriesItem> & Pick<ImportSeriesItem, 'id'>
) => {
importSeriesStore.setState((state) => {
const existingItem = state.items[itemData.id];
if (existingItem) {
return {
items: {
...state.items,
[itemData.id]: {
...existingItem,
...itemData,
},
},
};
}
return state;
});
};
export const removeImportSeriesItemByPath = (path: string) => {
importSeriesStore.setState((state) => {
const item = Object.values(state.items).find((i) => i.path === path);
if (!item) {
return state;
}
const { [item.id]: removed, ...items } = state.items;
return { items };
});
};
export const clearImportSeries = () => {
importSeriesStore.setState(defaultState);
};
export const startProcessing = () => {
importSeriesStore.setState((state) => {
const items = Object.values(state.items).reduce<string[]>((acc, item) => {
if (!item.hasSearched) {
acc.push(item.id);
}
return acc;
}, []);
return { isProcessing: true, lookupQueue: items };
});
};
export const stopProcessing = () => {
importSeriesStore.setState({ isProcessing: false, lookupQueue: [] });
};
export const addToLookupQueue = (id: string) => {
importSeriesStore.setState((state) => ({
lookupQueue: [...state.lookupQueue, id],
}));
};
export const removeFromLookupQueue = (id: string) => {
importSeriesStore.setState((state) => ({
lookupQueue: state.lookupQueue.filter((queuedId) => queuedId !== id),
}));
};
export const useIsCurrentLookupQueueItem = (id: string) => {
return importSeriesStore((state) => state.lookupQueue[0] === id);
};
export const useIsCurrentedItemQueued = (id: string) => {
return importSeriesStore((state) => state.lookupQueue.includes(id));
};
export const useLookupQueueHasItems = () => {
return importSeriesStore((state) => state.lookupQueue.length > 0);
};
export const useImportSeriesItem = (id: string) => {
return importSeriesStore((state) => state.items[id]);
};
export const useImportSeriesItems = () => {
return importSeriesStore(useShallow((state) => Object.values(state.items)));
};
export const getImportSeriesItems = (ids: string[]) => {
const state = importSeriesStore.getState();
return ids.reduce<ImportSeriesItem[]>((acc, id) => {
const item = state.items[id];
if (item != null) {
acc.push(item);
}
return acc;
}, []);
};

View File

@@ -0,0 +1,85 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import Series from 'Series/Series';
import {
getImportSeriesItems,
removeImportSeriesItemByPath,
} from './importSeriesStore';
export const useImportSeries = () => {
const queryClient = useQueryClient();
const { isPending, error, mutate } = useApiMutation<Series[], Series[]>({
path: '/series/import',
method: 'POST',
mutationOptions: {
onSuccess: (data, newSeries) => {
queryClient.invalidateQueries({ queryKey: ['/rootFolder'] });
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return data;
}
return [...oldSeries, ...data];
});
newSeries.forEach((series) => {
removeImportSeriesItemByPath(series.path);
});
},
},
});
const importSeries = useCallback(
(ids: string[]) => {
const items = getImportSeriesItems(ids);
const addedIds: string[] = [];
const allNewSeries = ids.reduce<Series[]>((acc, id) => {
const item = items.find((i) => i.id === id);
const selectedSeries = item?.selectedSeries;
// Make sure we have a selected series and the same series hasn't been added yet.
if (
selectedSeries &&
!acc.some((a) => a.tvdbId === selectedSeries.tvdbId)
) {
const newSeries: Series = {
...selectedSeries,
monitored: true,
monitorNewItems: 'all',
qualityProfileId: item.qualityProfileId,
path: item.path,
seriesType: item.seriesType,
seasonFolder: item.seasonFolder,
addOptions: {
monitor: item.monitor,
searchForMissingEpisodes: false,
searchForCutoffUnmetEpisodes: false,
},
tags: [],
};
newSeries.path = item.path;
addedIds.push(id);
acc.push(newSeries);
}
return acc;
}, []);
if (allNewSeries.length > 0) {
mutate(allNewSeries);
}
},
[mutate]
);
return {
isImporting: isPending,
importError: error,
importSeries,
};
};

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,154 @@
import React, { useCallback, useEffect, useState } 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 usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, kinds, sizes } from 'Helpers/Props';
import RootFolders from 'RootFolder/RootFolders';
import useRootFolders, { useAddRootFolder } from 'RootFolder/useRootFolders';
import { useIsWindows } from 'System/Status/useSystemStatus';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './ImportSeriesSelectFolder.css';
function ImportSeriesSelectFolder() {
const { isFetching, isFetched, error, data } = useRootFolders();
const { addRootFolder, isAdding, addError } = useAddRootFolder();
const isWindows = useIsWindows();
const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
useState(false);
const wasAdding = usePrevious(isAdding);
const hasRootFolders = data.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>) => {
addRootFolder({ path: value });
},
[addRootFolder]
);
useEffect(() => {
if (!isAdding && wasAdding && !addError) {
data.reduce((acc, item) => {
if (item.id > acc) {
return item.id;
}
return acc;
}, 0);
}
}, [isAdding, wasAdding, addError, data]);
return (
<PageContent title={translate('ImportSeries')}>
<PageContentBody>
{isFetching && !isFetched ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert>
) : null}
{!error && isFetched && (
<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}
{!isAdding && addError ? (
<Alert className={styles.addErrorAlert} kind={kinds.DANGER}>
{translate('AddRootFolderError')}
<ul>
{Array.isArray(addError.statusBody) ? (
addError.statusBody.map((e, index) => {
return <li key={index}>{e.errorMessage}</li>;
})
) : (
<li>{JSON.stringify(addError.statusBody)}</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);

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