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

Compare commits

...

339 Commits

Author SHA1 Message Date
Sonarr
ed0d9779db Automated API Docs update
ignore-downstream
2026-03-02 00:24:44 +00: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
1189 changed files with 71969 additions and 45738 deletions

View File

@@ -2,7 +2,7 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
{
"name": "Sonarr",
"image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0",
"image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,

View File

@@ -21,7 +21,13 @@ runs:
using: "composite"
steps:
- name: Setup .NET
uses: actions/setup-dotnet@v4
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
@@ -86,7 +92,7 @@ runs:
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
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

View File

@@ -3,7 +3,7 @@
outputFolder=_output
artifactsFolder=_artifacts
uiFolder="$outputFolder/UI"
framework="${FRAMEWORK:=net8.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

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

View File

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

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

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

View File

@@ -1,5 +1,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,
@@ -33,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 {
@@ -174,7 +171,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
}
if (eventType === 'downloadFailed') {
const { message, indexer } = data as DownloadFailedHistory;
const { indexer, message, source } = data as DownloadFailedHistory;
return (
<DescriptionList>
@@ -195,6 +192,10 @@ function HistoryDetails(props: HistoryDetailsProps) {
{message ? (
<DescriptionListItem title={translate('Message')} data={message} />
) : null}
{source ? (
<DescriptionListItem title={translate('Source')} data={source} />
) : 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 | number) => {
dispatch(setHistoryFilter({ selectedFilterKey }));
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,48 +1,25 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setHistoryFilter } from 'Store/Actions/historyActions';
function createHistorySelector() {
return createSelector(
(state: AppState) => state.history.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.history.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
import { setHistoryOption } from './historyOptionsStore';
import useHistory, { FILTER_BUILDER } from './useHistory';
type HistoryFilterModalProps = FilterModalProps<History>;
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
const sectionItems = useSelector(createHistorySelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const dispatch = useDispatch();
const { records } = useHistory();
const dispatchSetFilter = useCallback(
(payload: { selectedFilterKey: string | number }) => {
dispatch(setHistoryFilter(payload));
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
setHistoryOption('selectedFilterKey', selectedFilterKey);
},
[dispatch]
[]
);
return (
<FilterModal
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
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

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

View File

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

View File

@@ -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 | number) => {
dispatch(setQueueFilter({ selectedFilterKey }));
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';

View File

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

@@ -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,4 +1,4 @@
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';
@@ -9,17 +9,16 @@ 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;
@@ -27,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,
@@ -50,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) {
@@ -138,44 +127,24 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
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}>
@@ -198,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>
)}
@@ -216,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,6 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
@@ -12,6 +10,7 @@ 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';
@@ -21,11 +20,7 @@ import styles from './AddNewSeries.css';
function AddNewSeries() {
const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
const seriesCount = useSelector(
(state: AppState) => state.series.items.length
);
const hasSeries = useHasSeries();
const [term, setTerm] = useState(initialTerm);
const [isFetching, setIsFetching] = useState(false);
const query = useDebounce(term, term ? 300 : 0);
@@ -43,11 +38,7 @@ function AddNewSeries() {
setIsFetching(false);
}, []);
const {
isFetching: isFetchingApi,
error,
data = [],
} = useLookupSeries(query);
const { isFetching: isFetchingApi, error, data } = useLookupSeries(query);
useEffect(() => {
setIsFetching(isFetchingApi);
@@ -127,7 +118,7 @@ function AddNewSeries() {
</div>
)}
{!term && !seriesCount ? (
{!term && !hasSeries ? (
<div className={styles.message}>
<div className={styles.noSeriesText}>
{translate('NoSeriesHaveBeenAdded')}

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import {
AddSeriesOptions,
@@ -8,6 +7,7 @@ import {
} 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';
@@ -20,12 +20,12 @@ 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 createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import useIsWindows from 'System/useIsWindows';
import { useIsWindows } from 'System/Status/useSystemStatus';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import { useAddSeries } from './useAddSeries';
@@ -44,17 +44,16 @@ function AddNewSeriesModalContent({
}: AddNewSeriesModalContentProps) {
const { title, year, overview, images, folder } = series;
const options = useAddSeriesOptions();
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isSmallScreen = useAppDimension('isSmallScreen');
const isWindows = useIsWindows();
const {
isPending: isAdding,
error: addError,
mutate: addSeries,
} = useAddSeries();
const { isAdding, addError, addSeries } = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(options, {}, addError);
return {
...selectSettings(options, {}),
...getValidationFailures(addError),
};
}, [options, addError]);
const [seriesType, setSeriesType] = useState<SeriesType>(
@@ -92,12 +91,14 @@ function AddNewSeriesModalContent({
addSeries({
...series,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
addOptions: {
monitor: monitor.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
},
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value,
});
}, [
@@ -135,6 +136,7 @@ function AddNewSeriesModalContent({
className={styles.poster}
images={images}
size={250}
title={title}
/>
</div>
)}

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,6 +1,6 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
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';
@@ -10,8 +10,7 @@ import { icons, kinds, sizes } from 'Helpers/Props';
import { Statistics } from 'Series/Series';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import useExistingSeries from 'Series/useExistingSeries';
import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
@@ -35,10 +34,11 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
overview,
seriesType,
images,
isExcluded,
} = series;
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isExistingSeries = useExistingSeries(tvdbId);
const isSmallScreen = useAppDimension('isSmallScreen');
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
const seasonCount = statistics.seasonCount;
@@ -75,6 +75,7 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
size={250}
overflow={true}
lazy={false}
title={title}
/>
)}
@@ -100,6 +101,15 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
/>
) : 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}`}

View File

@@ -1,43 +1,62 @@
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
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';
import { updateItem } from 'Store/Actions/baseActions';
type AddSeriesPayload = AddSeries & AddSeriesOptions;
interface AddSeriesPayload
extends AddSeries,
Omit<
AddSeriesOptions,
'monitor' | 'searchForMissingEpisodes' | 'searchForCutoffUnmetEpisodes'
> {}
export const useLookupSeries = (query: string) => {
return useApiQuery<AddSeries[]>({
const DEFAULT_SERIES: AddSeries[] = [];
export const useLookupSeries = (query: string, isEnabled = true) => {
const result = useApiQuery<AddSeries[]>({
path: '/series/lookup',
queryParams: {
term: query,
},
queryOptions: {
enabled: !!query,
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 dispatch = useDispatch();
const queryClient = useQueryClient();
const onAddSuccess = useCallback(
(data: Series) => {
dispatch(updateItem({ section: 'series', ...data }));
},
[dispatch]
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 useApiMutation<Series, AddSeriesPayload>({
path: '/series',
method: 'POST',
mutationOptions: {
onSuccess: onAddSuccess,
},
});
return {
isAdding: isPending,
addError: error,
addSeries: mutate,
};
};

View File

@@ -2,6 +2,7 @@ import Series from 'Series/Series';
interface AddSeries extends Series {
folder: string;
isExcluded: boolean;
}
export default AddSeries;

View File

@@ -1,25 +1,23 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import {
setAddSeriesOption,
useAddSeriesOption,
} from 'AddSeries/addSeriesOptionsStore';
import { SelectProvider } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { 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 { clearImportSeries } from 'Store/Actions/importSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
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 dispatch = useDispatch();
const { rootFolderId: rootFolderIdString } = useParams<{
rootFolderId: string;
}>();
@@ -27,10 +25,12 @@ function ImportSeries() {
const {
isFetching: rootFoldersFetching,
isPopulated: rootFoldersPopulated,
isFetched: rootFoldersFetched,
error: rootFoldersError,
items: rootFolders,
} = useSelector((state: AppState) => state.rootFolders);
data: rootFolders,
} = useRootFolders();
useRootFolder(rootFolderId, false);
const { path, unmappedFolders } = useMemo(() => {
const rootFolder = rootFolders.find((r) => r.id === rootFolderId);
@@ -47,9 +47,7 @@ function ImportSeries() {
};
}, [rootFolders, rootFolderId]);
const qualityProfiles = useSelector(
(state: AppState) => state.settings.qualityProfiles.items
);
const qualityProfiles = useQualityProfilesData();
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
@@ -65,12 +63,10 @@ function ImportSeries() {
}, [unmappedFolders]);
useEffect(() => {
dispatch(fetchRootFolders({ id: rootFolderId, timeout: false }));
return () => {
dispatch(clearImportSeries());
clearImportSeries();
};
}, [rootFolderId, dispatch]);
}, [rootFolderId]);
useEffect(() => {
if (
@@ -79,13 +75,15 @@ function ImportSeries() {
) {
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
}
}, [defaultQualityProfileId, qualityProfiles, dispatch]);
}, [defaultQualityProfileId, qualityProfiles]);
return (
<SelectProvider items={items}>
<PageContent title={translate('ImportSeries')}>
<PageContentBody ref={scrollerRef}>
{rootFoldersFetching ? <LoadingIndicator /> : null}
{rootFoldersFetching && !rootFoldersFetched ? (
<LoadingIndicator />
) : null}
{!rootFoldersFetching && !!rootFoldersError ? (
<Alert kind={kinds.DANGER}>
@@ -95,7 +93,7 @@ function ImportSeries() {
{!rootFoldersError &&
!rootFoldersFetching &&
rootFoldersPopulated &&
rootFoldersFetched &&
!unmappedFolders.length ? (
<Alert kind={kinds.INFO}>
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
@@ -103,20 +101,14 @@ function ImportSeries() {
) : null}
{!rootFoldersError &&
!rootFoldersFetching &&
rootFoldersPopulated &&
rootFoldersFetched &&
!!unmappedFolders.length &&
scrollerRef.current ? (
<ImportSeriesTable
unmappedFolders={unmappedFolders}
scrollerRef={scrollerRef}
/>
<ImportSeriesTable items={items} scrollerRef={scrollerRef} />
) : null}
</PageContentBody>
{!rootFoldersError &&
!rootFoldersFetching &&
!!unmappedFolders.length ? (
{!rootFoldersError && rootFoldersFetched && !!unmappedFolders.length ? (
<ImportSeriesFooter />
) : null}
</PageContent>

View File

@@ -1,12 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
AddSeriesOptions,
setAddSeriesOption,
useAddSeriesOptions,
} from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { useSelect } from 'App/Select/SelectContext';
import CheckInput from 'Components/Form/CheckInput';
import FormInputGroup from 'Components/Form/FormInputGroup';
import Icon from 'Components/Icon';
@@ -17,21 +15,22 @@ import PageContentFooter from 'Components/Page/PageContentFooter';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesMonitor, SeriesType } from 'Series/Series';
import {
cancelLookupSeries,
importSeries,
lookupUnsearchedSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import {
ImportSeriesItem,
startProcessing,
stopProcessing,
updateImportSeriesItem,
useImportSeriesItems,
useLookupQueueHasItems,
} from './importSeriesStore';
import { useImportSeries } from './useImportSeries';
import styles from './ImportSeriesFooter.css';
type MixedType = 'mixed';
function ImportSeriesFooter() {
const dispatch = useDispatch();
const {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
@@ -39,9 +38,8 @@ function ImportSeriesFooter() {
seasonFolder: defaultSeasonFolder,
} = useAddSeriesOptions();
const { isLookingUpSeries, isImporting, items, importError } = useSelector(
(state: AppState) => state.importSeries
);
const items = useImportSeriesItems();
const isLookingUpSeries = useLookupQueueHasItems();
const [monitor, setMonitor] = useState<SeriesMonitor | MixedType>(
defaultMonitor
@@ -56,11 +54,9 @@ function ImportSeriesFooter() {
defaultSeasonFolder
);
const [selectState] = useSelect();
const { selectedCount, getSelectedIds } = useSelect<ImportSeriesItem>();
const selectedIds = useMemo(() => {
return getSelectedIds(selectState.selectedState, (id) => id);
}, [selectState.selectedState]);
const { importSeries, isImporting, importError } = useImportSeries();
const {
hasUnsearchedItems,
@@ -92,7 +88,7 @@ function ImportSeriesFooter() {
isSeasonFolderMixed = true;
}
if (!item.isPopulated) {
if (!item.hasSearched) {
hasUnsearchedItems = true;
}
});
@@ -127,30 +123,27 @@ function ImportSeriesFooter() {
setAddSeriesOption(name as keyof AddSeriesOptions, value);
selectedIds.forEach((id) => {
dispatch(
// @ts-expect-error - actions are not typed
setImportSeriesValue({
id,
[name]: value,
})
);
getSelectedIds().forEach((id) => {
updateImportSeriesItem({
id,
[name]: value,
});
});
},
[selectedIds, dispatch]
[getSelectedIds]
);
const handleLookupPress = useCallback(() => {
dispatch(lookupUnsearchedSeries());
}, [dispatch]);
startProcessing();
}, []);
const handleCancelLookupPress = useCallback(() => {
dispatch(cancelLookupSeries());
}, [dispatch]);
stopProcessing();
}, []);
const handleImportPress = useCallback(() => {
dispatch(importSeries({ ids: selectedIds }));
}, [selectedIds, dispatch]);
importSeries(getSelectedIds());
}, [importSeries, getSelectedIds]);
useEffect(() => {
if (isMonitorMixed && monitor !== 'mixed') {
@@ -187,8 +180,6 @@ function ImportSeriesFooter() {
}
}, [defaultSeasonFolder, isSeasonFolderMixed, seasonFolder]);
const selectedCount = selectedIds.length;
return (
<PageContentFooter>
<div className={styles.inputContainer}>
@@ -293,12 +284,12 @@ function ImportSeriesFooter() {
title={translate('ImportErrors')}
body={
<ul>
{Array.isArray(importError.responseJSON) ? (
importError.responseJSON.map((error, index) => {
{Array.isArray(importError.statusBody) ? (
importError.statusBody.map((error, index) => {
return <li key={index}>{error.errorMessage}</li>;
})
) : (
<li>{JSON.stringify(importError.responseJSON)}</li>
<li>{JSON.stringify(importError.statusBody)}</li>
)}
</ul>
}

View File

@@ -1,39 +1,29 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
import 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 { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
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';
function createItemSelector(id: string) {
return createSelector(
(state: AppState) => state.importSeries.items,
(items) => {
return (
items.find((item) => {
return item.id === id;
}) || ({} as ImportSeries)
);
}
);
}
interface ImportSeriesRowProps {
id: string;
unmappedFolder: UnamppedFolderItem;
}
function ImportSeriesRow({ id }: ImportSeriesRowProps) {
const dispatch = useDispatch();
function ImportSeriesRow({ unmappedFolder }: ImportSeriesRowProps) {
const id = unmappedFolder.id;
const item = useImportSeriesItem(unmappedFolder.id);
const {
relativePath,
@@ -42,45 +32,45 @@ function ImportSeriesRow({ id }: ImportSeriesRowProps) {
seasonFolder,
seriesType,
selectedSeries,
} = useSelector(createItemSelector(id));
} = item ?? {};
const isExistingSeries = useSelector(
createExistingSeriesSelector(selectedSeries?.tvdbId)
);
const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId);
const [selectState, selectDispatch] = useSelect();
const { getIsSelected, toggleSelected, toggleDisabled } =
useSelect<ImportSeriesItem>();
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
dispatch(
// @ts-expect-error - actions are not typed
setImportSeriesValue({
id,
[name]: value,
})
);
updateImportSeriesItem({ id, [name]: value });
},
[id, dispatch]
[id]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey }: SelectStateInputProps) => {
selectDispatch({
type: 'toggleSelected',
({ id, value, shiftKey }: SelectStateInputProps<string>) => {
toggleSelected({
id,
isSelected: value,
shiftKey,
});
},
[selectDispatch]
[toggleSelected]
);
useEffect(() => {
toggleDisabled(id, !selectedSeries || isExistingSeries);
}, [id, selectedSeries, isExistingSeries, toggleDisabled]);
useEffect(() => {
toggleSelected({ id, isSelected: !!selectedSeries, shiftKey: false });
}, [id, selectedSeries, toggleSelected]);
return (
<>
<VirtualTableSelectCell
<VirtualTableSelectCell<string>
inputClassName={styles.selectInput}
id={id}
isSelected={selectState.selectedState[id]}
isSelected={getIsSelected(id)}
isDisabled={!selectedSeries || isExistingSeries}
onSelectedChange={handleSelectedChange}
/>

View File

@@ -1,33 +1,25 @@
import React, { RefObject, useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import React, { RefObject, useCallback, useRef } from 'react';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
import { useAppDimension } from 'App/appStore';
import { useSelect } from 'App/Select/SelectContext';
import VirtualTable from 'Components/Table/VirtualTable';
import usePrevious from 'Helpers/Hooks/usePrevious';
import {
queueLookupSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import { UnmappedFolder } from 'typings/RootFolder';
import ImportSeriesHeader from './ImportSeriesHeader';
import ImportSeriesRow from './ImportSeriesRow';
import {
UnamppedFolderItem,
useEnsureImportSeriesItems,
} from './importSeriesStore';
import styles from './ImportSeriesTable.css';
const ROW_HEIGHT = 52;
interface RowItemData {
items: ImportSeries[];
items: UnamppedFolderItem[];
}
interface ImportSeriesTableProps {
unmappedFolders: UnmappedFolder[];
items: UnamppedFolderItem[];
scrollerRef: RefObject<HTMLElement>;
}
@@ -49,138 +41,34 @@ function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
}}
className={styles.row}
>
<ImportSeriesRow key={item.id} id={item.id} />
<ImportSeriesRow key={item.id} unmappedFolder={item} />
</div>
);
}
function ImportSeriesTable({
unmappedFolders,
scrollerRef,
}: ImportSeriesTableProps) {
const dispatch = useDispatch();
const { monitor, qualityProfileId, seriesType, seasonFolder } =
useAddSeriesOptions();
const items = useSelector((state: AppState) => state.importSeries.items);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const allSeries = useSelector(createAllSeriesSelector());
const [selectState, selectDispatch] = useSelect();
const defaultValues = useRef({
monitor,
qualityProfileId,
seriesType,
seasonFolder,
});
function ImportSeriesTable({ items, scrollerRef }: ImportSeriesTableProps) {
const isSmallScreen = useAppDimension('isSmallScreen');
const { allSelected, allUnselected, selectAll, unselectAll, useHasItems } =
useSelect();
const listRef = useRef<FixedSizeList<RowItemData>>(null);
const initialUnmappedFolders = useRef(unmappedFolders);
const previousItems = usePrevious(items);
const { allSelected, allUnselected, selectedState } = selectState;
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
selectDispatch({
type: value ? 'selectAll' : 'unselectAll',
});
if (value) {
selectAll();
} else {
unselectAll();
}
},
[selectDispatch]
[selectAll, unselectAll]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey }: SelectStateInputProps) => {
selectDispatch({
type: 'toggleSelected',
id,
isSelected: value,
shiftKey,
});
},
[selectDispatch]
);
const hasSelectItems = useHasItems();
const handleRemoveSelectedStateItem = useCallback(
(id: string) => {
selectDispatch({
type: 'removeItem',
id,
});
},
[selectDispatch]
);
useEnsureImportSeriesItems(items);
useEffect(() => {
initialUnmappedFolders.current.forEach(({ name, path, relativePath }) => {
dispatch(
queueLookupSeries({
name,
path,
relativePath,
term: name,
})
);
dispatch(
// @ts-expect-error - actions are not typed
setImportSeriesValue({
id: name,
...defaultValues.current,
})
);
});
}, [dispatch]);
useEffect(() => {
previousItems?.forEach((prevItem) => {
const { id } = prevItem;
const item = items.find((i) => i.id === id);
if (!item) {
handleRemoveSelectedStateItem(id);
return;
}
const selectedSeries = item.selectedSeries;
const isSelected = selectedState[id];
const isExistingSeries =
!!selectedSeries &&
allSeries.some((s) => s.tvdbId === selectedSeries.tvdbId);
if (
(!selectedSeries && prevItem.selectedSeries) ||
(isExistingSeries && !prevItem.selectedSeries)
) {
handleSelectedChange({ id, value: false, shiftKey: false });
return;
}
if (isSelected && (!selectedSeries || isExistingSeries)) {
handleSelectedChange({ id, value: false, shiftKey: false });
return;
}
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
handleSelectedChange({ id, value: true, shiftKey: false });
return;
}
});
}, [
allSeries,
items,
previousItems,
selectedState,
handleRemoveSelectedStateItem,
handleSelectedChange,
]);
if (!items.length) {
if (!items.length || !hasSelectItems) {
return null;
}

View File

@@ -1,9 +1,8 @@
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import useExistingSeries from 'Series/useExistingSeries';
import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSearchResult.css';
@@ -22,7 +21,7 @@ function ImportSeriesSearchResult({
network,
onPress,
}: ImportSeriesSearchResultProps) {
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const isExistingSeries = useExistingSeries(tvdbId);
const handlePress = useCallback(() => {
onPress(tvdbId);

View File

@@ -7,23 +7,27 @@ import {
useFloating,
useInteractions,
} from '@floating-ui/react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import 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 {
queueLookupSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
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';
@@ -37,29 +41,23 @@ function ImportSeriesSelectSeries({
id,
onInputChange,
}: ImportSeriesSelectSeriesProps) {
const dispatch = useDispatch();
const isLookingUpSeries = useSelector(
(state: AppState) => state.importSeries.isLookingUpSeries
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 {
error,
isFetching = true,
isPopulated = false,
items = [],
isQueued = true,
selectedSeries,
isExistingSeries,
term: itemTerm,
// @ts-expect-error - ignoring this for now
} = useSelector(createImportSeriesItemSelector(id, { id }));
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();
const [term, setTerm] = useState('');
const [isOpen, setIsOpen] = useState(false);
const errorMessage = getErrorMessage(error);
const isLookingUpSeries = isFetching || isQueued;
const handlePress = useCallback(() => {
setIsOpen((prevIsOpen) => !prevIsOpen);
@@ -67,48 +65,26 @@ function ImportSeriesSelectSeries({
const handleSearchInputChange = useCallback(
({ value }: InputChanged<string>) => {
if (seriesLookupTimeout.current) {
clearTimeout(seriesLookupTimeout.current);
}
setTerm(value);
seriesLookupTimeout.current = setTimeout(() => {
dispatch(
queueLookupSeries({
name: id,
term: value,
topOfQueue: true,
})
);
}, 200);
addToLookupQueue(id);
},
[id, dispatch]
[id]
);
const handleRefreshPress = useCallback(() => {
dispatch(
queueLookupSeries({
name: id,
term,
topOfQueue: true,
})
);
}, [id, term, dispatch]);
refetch();
}, [refetch]);
const handleSeriesSelect = useCallback(
(tvdbId: number) => {
setIsOpen(false);
const selectedSeries = items.find((item) => item.tvdbId === tvdbId)!;
const selectedSeries = data.find((item) => item.tvdbId === tvdbId)!;
dispatch(
// @ts-expect-error - actions are not typed
setImportSeriesValue({
id,
selectedSeries,
})
);
updateImportSeriesItem({
id,
selectedSeries,
});
if (selectedSeries.seriesType !== 'standard') {
onInputChange({
@@ -117,12 +93,24 @@ function ImportSeriesSelectSeries({
});
}
},
[id, items, dispatch, onInputChange]
[id, data, onInputChange]
);
useEffect(() => {
setTerm(itemTerm);
}, [itemTerm]);
if (isFetched) {
updateImportSeriesItem({
id,
hasSearched: isFetched,
selectedSeries: data[0],
});
removeFromLookupQueue(id);
}
}, [id, isFetched, data]);
useEffect(() => {
setTerm(name);
}, [name]);
const { refs, context, floatingStyles } = useFloating({
middleware: [
@@ -149,11 +137,11 @@ function ImportSeriesSelectSeries({
<>
<div ref={refs.setReference} {...getReferenceProps()}>
<Link className={styles.button} component="div" onPress={handlePress}>
{isLookingUpSeries && isQueued && !isPopulated ? (
{isLookingUpSeries && isQueued && !isFetched ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isPopulated && selectedSeries && isExistingSeries ? (
{isFetched && selectedSeries && isExistingSeries ? (
<Icon
className={styles.warningIcon}
name={icons.WARNING}
@@ -161,7 +149,7 @@ function ImportSeriesSelectSeries({
/>
) : null}
{isPopulated && selectedSeries ? (
{isFetched && selectedSeries ? (
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
@@ -170,7 +158,7 @@ function ImportSeriesSelectSeries({
/>
) : null}
{isPopulated && !selectedSeries ? (
{isFetched && !selectedSeries ? (
<div>
<Icon
className={styles.warningIcon}
@@ -200,6 +188,7 @@ function ImportSeriesSelectSeries({
</div>
</Link>
</div>
{isOpen ? (
<FloatingPortal id="portal-root">
<div
@@ -234,7 +223,7 @@ function ImportSeriesSelectSeries({
</div>
<div className={styles.results}>
{items.map((item) => {
{data.map((item) => {
return (
<ImportSeriesSearchResult
key={item.tvdbId}

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,6 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
@@ -13,28 +11,24 @@ import PageContentBody from 'Components/Page/PageContentBody';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, kinds, sizes } from 'Helpers/Props';
import RootFolders from 'RootFolder/RootFolders';
import {
addRootFolder,
fetchRootFolders,
} from 'Store/Actions/rootFolderActions';
import useIsWindows from 'System/useIsWindows';
import 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 dispatch = useDispatch();
const { isFetching, isPopulated, isSaving, error, saveError, items } =
useSelector((state: AppState) => state.rootFolders);
const { isFetching, isFetched, error, data } = useRootFolders();
const { addRootFolder, isAdding, addError } = useAddRootFolder();
const isWindows = useIsWindows();
const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
useState(false);
const wasSaving = usePrevious(isSaving);
const wasAdding = usePrevious(isAdding);
const hasRootFolders = items.length > 0;
const hasRootFolders = data.length > 0;
const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows';
const badFolderExample = isWindows
? 'C:\\tv shows\\the simpsons'
@@ -50,18 +44,14 @@ function ImportSeriesSelectFolder() {
const handleNewRootFolderSelect = useCallback(
({ value }: InputChanged<string>) => {
dispatch(addRootFolder({ path: value }));
addRootFolder({ path: value });
},
[dispatch]
[addRootFolder]
);
useEffect(() => {
dispatch(fetchRootFolders());
}, [dispatch]);
useEffect(() => {
if (!isSaving && wasSaving && !saveError) {
items.reduce((acc, item) => {
if (!isAdding && wasAdding && !addError) {
data.reduce((acc, item) => {
if (item.id > acc) {
return item.id;
}
@@ -69,18 +59,18 @@ function ImportSeriesSelectFolder() {
return acc;
}, 0);
}
}, [isSaving, wasSaving, saveError, items]);
}, [isAdding, wasAdding, addError, data]);
return (
<PageContent title={translate('ImportSeries')}>
<PageContentBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isFetching && !isFetched ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert>
) : null}
{!error && isPopulated && (
{!error && isFetched && (
<div>
<div className={styles.header}>
{translate('LibraryImportSeriesHeader')}
@@ -118,17 +108,17 @@ function ImportSeriesSelectFolder() {
</div>
) : null}
{!isSaving && saveError ? (
{!isAdding && addError ? (
<Alert className={styles.addErrorAlert} kind={kinds.DANGER}>
{translate('AddRootFolderError')}
<ul>
{Array.isArray(saveError.responseJSON) ? (
saveError.responseJSON.map((e, index) => {
{Array.isArray(addError.statusBody) ? (
addError.statusBody.map((e, index) => {
return <li key={index}>{e.errorMessage}</li>;
})
) : (
<li>{JSON.stringify(saveError.responseJSON)}</li>
<li>{JSON.stringify(addError.statusBody)}</li>
)}
</ul>
</Alert>

View File

@@ -1,4 +1,4 @@
import { createPersist } from 'Helpers/createPersist';
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeriesOptions {
@@ -12,9 +12,8 @@ export interface AddSeriesOptions {
tags: number[];
}
const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
'add_series_options',
() => {
const { useOptions, useOption, setOption } =
createOptionsStore<AddSeriesOptions>('add_series_options', () => {
return {
rootFolderPath: '',
monitor: 'all',
@@ -25,25 +24,8 @@ const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
searchForCutoffUnmetEpisodes: false,
tags: [],
};
}
);
});
export const useAddSeriesOptions = () => {
return addSeriesOptionsStore((state) => state);
};
export const useAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K
) => {
return addSeriesOptionsStore((state) => state[key]);
};
export const setAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K,
value: AddSeriesOptions[K]
) => {
addSeriesOptionsStore.setState((state) => ({
...state,
[key]: value,
}));
};
export const useAddSeriesOptions = useOptions;
export const useAddSeriesOption = useOption;
export const setAddSeriesOption = setOption;

View File

@@ -1,4 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
import React from 'react';
import DocumentTitle from 'react-document-title';
@@ -7,14 +7,13 @@ import { Store } from 'redux';
import Page from 'Components/Page/Page';
import ApplyTheme from './ApplyTheme';
import AppRoutes from './AppRoutes';
import { queryClient } from './queryClient';
interface AppProps {
store: Store;
history: ConnectedRouterProps['history'];
}
const queryClient = new QueryClient();
function App({ store, history }: AppProps) {
return (
<DocumentTitle title={window.Sonarr.instanceName}>

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
@@ -9,12 +8,11 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import { fetchUpdates } from 'Store/Actions/systemActions';
import UpdateChanges from 'System/Updates/UpdateChanges';
import useUpdates from 'System/Updates/useUpdates';
import Update from 'typings/Update';
import translate from 'Utilities/String/translate';
import AppState from './State/AppState';
import { useAppValues } from './appStore';
import styles from './AppUpdatedModalContent.css';
function mergeUpdates(items: Update[], version: string, prevVersion?: string) {
@@ -64,9 +62,8 @@ interface AppUpdatedModalContentProps {
}
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const dispatch = useDispatch();
const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { isFetched, error, data } = useUpdates();
const { version, prevVersion } = useAppValues('version', 'prevVersion');
const { isFetched, error, data, refetch } = useUpdates();
const previousVersion = usePrevious(version);
const { onModalClose } = props;
@@ -77,15 +74,11 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
}, []);
useEffect(() => {
dispatch(fetchUpdates());
}, [dispatch]);
useEffect(() => {
if (version !== previousVersion) {
dispatch(fetchUpdates());
refetch();
}
}, [version, previousVersion, dispatch]);
}, [version, previousVersion, refetch]);
return (
<ModalContent onModalClose={onModalClose}>

View File

@@ -0,0 +1,35 @@
import React, { createContext, PropsWithChildren } from 'react';
import useSelectStore, {
Id,
SelectStoreModel,
} from 'App/Select/useSelectStore';
interface SelectProviderProps<T extends SelectStoreModel<Id>>
extends PropsWithChildren {
items: Array<T>;
}
const SelectContext = createContext<
ReturnType<typeof useSelectStore> | undefined
>(undefined);
export function SelectProvider<T extends SelectStoreModel<Id>>({
items,
children,
}: SelectProviderProps<T>) {
const value = useSelectStore<T>(items);
return (
<SelectContext.Provider value={value}>{children}</SelectContext.Provider>
);
}
export function useSelect<T extends SelectStoreModel<Id>>() {
const context = React.useContext(SelectContext);
if (context === undefined) {
throw new Error('useSelect must be used within a SelectProvider');
}
return context as ReturnType<typeof useSelectStore<T>>;
}

View File

@@ -0,0 +1,314 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { create, useStore } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import getToggledRange from 'Utilities/Table/getToggledRange';
export type Id = string | number;
export type SelectStoreReturnType<T extends SelectStoreModel<Id>> = ReturnType<
typeof useSelectStore<T>
>;
type ItemState<T extends SelectStoreModel<Id>> = Map<T['id'], ItemStateValue>;
interface ItemStateValue {
isSelected: boolean;
isDisabled?: boolean;
}
export interface SelectStoreModel<TId extends Id> {
id: TId;
}
export interface SelectStore<T extends SelectStoreModel<Id>> {
itemState: Map<T['id'], ItemStateValue>;
lastToggled: T['id'] | null;
items: T[];
}
interface ItemSelectState {
allSelected: boolean;
allUnselected: boolean;
anySelected: boolean;
selectedCount: number;
}
const initialState = <T extends SelectStoreModel<Id>>(
items: T[] = []
): SelectStore<T> => ({
itemState: new Map<T['id'], ItemStateValue>(),
lastToggled: null,
items,
});
function toggleAll<T extends SelectStoreModel<Id>>(
itemState: ItemState<T>,
isSelected: boolean
) {
const newItemState = new Map(itemState);
newItemState.forEach((value, key) => {
newItemState.set(key, {
isSelected: value.isDisabled ? value.isSelected : isSelected,
isDisabled: value.isDisabled,
});
});
return newItemState;
}
export default function useSelectStore<T extends SelectStoreModel<Id>>(
items: SelectStoreModel<T['id']>[]
) {
const store = useRef(
create<SelectStore<T>>(() => initialState(items as T[]))
);
const [itemSelectState, setItemSelectState] = useState<ItemSelectState>({
allSelected: false,
allUnselected: true,
anySelected: false,
selectedCount: 0,
});
const reset = useCallback(() => {
store.current.setState(initialState(items as T[]), true);
}, [items]);
const selectAll = useCallback(() => {
store.current.setState((state) => {
const newItemState = toggleAll(state.itemState, true);
return {
lastToggled: null,
itemState: newItemState,
};
});
}, []);
const unselectAll = useCallback(() => {
store.current.setState((state) => {
const newItemState = toggleAll(state.itemState, false);
return {
lastToggled: null,
itemState: newItemState,
};
});
}, []);
const toggleSelected = useCallback(
({
id,
isSelected,
shiftKey,
}: {
id: T['id'];
isSelected: boolean | null;
shiftKey: boolean;
}) => {
store.current.setState((state) => {
const lastToggled = state.lastToggled;
const nextSelectedState = new Map(state.itemState);
const currentItemState = nextSelectedState.get(id);
if (isSelected == null) {
nextSelectedState.delete(id);
} else if (!currentItemState?.isDisabled) {
nextSelectedState.set(id, {
isSelected,
isDisabled: currentItemState?.isDisabled,
});
if (shiftKey && lastToggled) {
const { lower, upper } = getToggledRange(
state.items,
id,
lastToggled
);
for (let i = lower; i < upper; i++) {
if (!nextSelectedState.get(state.items[i].id)?.isDisabled) {
nextSelectedState.set(state.items[i].id, {
isSelected,
isDisabled: currentItemState?.isDisabled,
});
}
}
}
}
return {
...state,
lastToggled: id,
itemState: nextSelectedState,
};
});
},
[]
);
const toggleDisabled = useCallback((id: T['id'], isDisabled: boolean) => {
store.current.setState((state) => {
const currentItemState = state.itemState.get(id);
if (currentItemState) {
const newItemState = new Map(state.itemState);
newItemState.set(id, {
...currentItemState,
isDisabled,
});
return {
itemState: newItemState,
};
}
return state;
});
}, []);
const getSelectedIds = useCallback((): Array<T['id']> => {
const iState = store.current.getState().itemState;
return Array.from(iState.entries()).reduce<T['id'][]>(
(acc, [id, value]) => {
if (value.isSelected) {
acc.push(id);
}
return acc;
},
[]
);
}, []);
const getIsSelected = useCallback((id: T['id']): boolean => {
const item = store.current.getState().itemState.get(id);
return item?.isSelected ?? false;
}, []);
const useIsSelected = (id: T['id']) => {
return useStore(
store.current,
useShallow((state) => {
const item = state.itemState.get(id);
return item?.isSelected ?? false;
})
);
};
const useSelectedIds = () => {
return useStore(
store.current,
useShallow((state) => {
return state.itemState
.entries()
.reduce<T['id'][]>((acc, [id, value]) => {
if (value.isSelected) {
acc.push(id);
}
return acc;
}, []);
})
);
};
const useHasItems = () => {
return useStore(
store.current,
useShallow((state) => {
return state.itemState.size > 0;
})
);
};
useEffect(() => {
const unsubscribe = store.current.subscribe((state) => {
const itemState = state.itemState;
const { allSelected, allUnselected, anySelected, selectedCount } =
itemState.values().reduce(
(acc, item) => {
acc.allSelected =
acc.allSelected && !!(item.isSelected || item.isDisabled);
acc.allUnselected =
acc.allUnselected && (!item.isSelected || !!item.isDisabled);
acc.anySelected = acc.anySelected || item.isSelected;
acc.selectedCount += item.isSelected ? 1 : 0;
return acc;
},
{
allSelected:
itemState.size > 0 &&
itemState.values().some((i) => i.isSelected),
allUnselected: true,
anySelected: false,
selectedCount: 0,
}
);
setItemSelectState((s) => {
if (
s.allSelected === allSelected &&
s.allUnselected === allUnselected &&
s.anySelected === anySelected &&
s.selectedCount === selectedCount
) {
return s;
}
return {
allSelected,
allUnselected,
anySelected,
selectedCount,
};
});
});
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
store.current.setState((state) => {
const nextItemState = items.reduce((acc: ItemState<T>, item) => {
const id = item.id;
const existingItem = state.itemState.get(id);
acc.set(
id,
existingItem ?? {
isSelected: false,
isDisabled: false,
}
);
return acc;
}, new Map<T['id'], ItemStateValue>());
return {
itemState: nextItemState,
lastToggled: null,
items: items as T[],
};
});
}, [items]);
return {
...itemSelectState,
getIsSelected,
getSelectedIds,
reset,
selectAll,
toggleDisabled,
toggleSelected,
unselectAll,
useHasItems,
useIsSelected,
useSelectedIds,
};
}

View File

@@ -1,86 +0,0 @@
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect } from 'react';
import useSelectState, {
SelectState,
SelectStateModel,
} from 'Helpers/Hooks/useSelectState';
import ModelBase from './ModelBase';
export type SelectContextAction =
| { type: 'reset' }
| { type: 'selectAll' }
| { type: 'unselectAll' }
| {
type: 'toggleSelected';
id: number | string;
isSelected: boolean | null;
shiftKey: boolean;
}
| {
type: 'removeItem';
id: number | string;
}
| {
type: 'updateItems';
items: ModelBase[];
};
export type SelectDispatch = (action: SelectContextAction) => void;
interface SelectProviderOptions<T extends SelectStateModel> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: any;
items: Array<T>;
}
const SelectContext = React.createContext<
[SelectState, SelectDispatch] | undefined
>(cloneDeep(undefined));
export function SelectProvider<T extends SelectStateModel>(
props: SelectProviderOptions<T>
) {
const { items } = props;
const [state, dispatch] = useSelectState();
const dispatchWrapper = useCallback(
(action: SelectContextAction) => {
switch (action.type) {
case 'reset':
case 'removeItem':
dispatch(action);
break;
default:
dispatch({
...action,
items,
});
break;
}
},
[items, dispatch]
);
const value: [SelectState, SelectDispatch] = [state, dispatchWrapper];
useEffect(() => {
dispatch({ type: 'updateItems', items });
}, [items, dispatch]);
return (
<SelectContext.Provider value={value}>
{props.children}
</SelectContext.Provider>
);
}
export function useSelect() {
const context = React.useContext(SelectContext);
if (context === undefined) {
throw new Error('useSelect must be used within a SelectProvider');
}
return context;
}

View File

@@ -1,7 +1,7 @@
import Column from 'Components/Table/Column';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { ValidationFailure } from 'typings/pending';
import { Filter, FilterBuilderProp } from './AppState';
export interface Error {
status?: number;

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
import Blocklist from 'typings/Blocklist';
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from './AppSectionState';
interface BlocklistAppState
extends AppSectionState<Blocklist>,
AppSectionFilterState<Blocklist>,
PagedAppSectionState,
TableAppSectionState {
isRemoving: boolean;
}
export default BlocklistAppState;

View File

@@ -1,29 +0,0 @@
import moment from 'moment';
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import { CalendarView } from 'Calendar/calendarViews';
import { CalendarItem } from 'typings/Calendar';
interface CalendarOptions {
showEpisodeInformation: boolean;
showFinaleIcon: boolean;
showSpecialIcon: boolean;
showCutoffUnmetIcon: boolean;
collapseMultipleEpisodes: boolean;
fullColorEvents: boolean;
}
interface CalendarAppState
extends AppSectionState<CalendarItem>,
AppSectionFilterState<CalendarItem> {
searchMissingCommandId: number | null;
start: moment.Moment;
end: moment.Moment;
dates: string[];
time: string;
view: CalendarView;
options: CalendarOptions;
}
export default CalendarAppState;

View File

@@ -1,8 +1,5 @@
import { CustomFilter } from './AppState';
interface ClientSideCollectionAppState {
totalItems: number;
customFilters: CustomFilter[];
}
export default ClientSideCollectionAppState;

View File

@@ -1,6 +0,0 @@
import AppSectionState from 'App/State/AppSectionState';
import Command from 'Commands/Command';
export type CommandAppState = AppSectionState<Command>;
export default CommandAppState;

View File

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

View File

@@ -1,10 +0,0 @@
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
interface EpisodeFilesAppState
extends AppSectionState<EpisodeFile>,
AppSectionDeleteState {}
export default EpisodeFilesAppState;

View File

@@ -1,9 +0,0 @@
import AppSectionState from 'App/State/AppSectionState';
import Column from 'Components/Table/Column';
import Episode from 'Episode/Episode';
interface EpisodesAppState extends AppSectionState<Episode> {
columns: Column[];
}
export default EpisodesAppState;

View File

@@ -1,16 +0,0 @@
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from 'App/State/AppSectionState';
import History from 'typings/History';
export type SeriesHistoryAppState = AppSectionState<History>;
interface HistoryAppState
extends AppSectionState<History>,
AppSectionFilterState<History>,
PagedAppSectionState,
TableAppSectionState {}
export default HistoryAppState;

View File

@@ -1,29 +0,0 @@
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
import { Error } from './AppSectionState';
export interface ImportSeries {
id: string;
error?: Error;
isFetching: boolean;
isPopulated: boolean;
isQueued: boolean;
items: Series[];
monitor: SeriesMonitor;
path: string;
qualityProfileId: number;
relativePath: string;
seasonFolder: boolean;
selectedSeries?: Series;
seriesType: SeriesType;
term: string;
}
interface ImportSeriesAppState {
isLookingUpSeries: false;
isImporting: false;
isImported: false;
importError: Error | null;
items: ImportSeries[];
}
export default ImportSeriesAppState;

View File

@@ -1,21 +0,0 @@
import AppSectionState from 'App/State/AppSectionState';
import ImportMode from 'InteractiveImport/ImportMode';
import InteractiveImport from 'InteractiveImport/InteractiveImport';
interface FavoriteFolder {
folder: string;
}
interface RecentFolder {
folder: string;
lastUsed: string;
}
interface InteractiveImportAppState extends AppSectionState<InteractiveImport> {
originalItems: InteractiveImport[];
importMode: ImportMode;
favoriteFolders: FavoriteFolder[];
recentFolders: RecentFolder[];
}
export default InteractiveImportAppState;

View File

@@ -1,15 +0,0 @@
import ModelBase from 'App/ModelBase';
import AppSectionState from 'App/State/AppSectionState';
export type MessageType = 'error' | 'info' | 'success' | 'warning';
export interface Message extends ModelBase {
hideAfter: number;
message: string;
name: string;
type: MessageType;
}
type MessagesAppState = AppSectionState<Message>;
export default MessagesAppState;

View File

@@ -1,6 +0,0 @@
import { AppSectionProviderState } from 'App/State/AppSectionState';
import Metadata from 'typings/Metadata';
type MetadataAppState = AppSectionProviderState<Metadata>;
export default MetadataAppState;

View File

@@ -1,9 +0,0 @@
import { Error } from './AppSectionState';
interface OAuthAppState {
authorizing: boolean;
result: Record<string, unknown> | null;
error: Error;
}
export default OAuthAppState;

View File

@@ -1,15 +0,0 @@
import ModelBase from 'App/ModelBase';
import AppSectionState from 'App/State/AppSectionState';
export interface OrganizePreviewModel extends ModelBase {
seriesId: number;
seasonNumber: number;
episodeNumbers: number[];
episodeFileId: number;
existingPath: string;
newPath: string;
}
type OrganizePreviewAppState = AppSectionState<OrganizePreviewModel>;
export default OrganizePreviewAppState;

View File

@@ -1,29 +0,0 @@
interface BasePath {
name: string;
path: string;
size: number;
lastModified: string;
}
interface File extends BasePath {
type: 'file';
}
interface Folder extends BasePath {
type: 'folder';
}
export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent';
export type Path = File | Folder;
interface PathsAppState {
currentPath: string;
isFetching: boolean;
isPopulated: boolean;
error: Error;
directories: Folder[];
files: File[];
parent: string | null;
}
export default PathsAppState;

View File

@@ -1,22 +0,0 @@
import AppSectionState from 'App/State/AppSectionState';
import Field, { FieldSelectOption } from 'typings/Field';
export interface ProviderOptions {
fields?: Field[];
}
interface ProviderOptionsDevice {
id: string;
name: string;
}
interface ProviderOptionsAppState {
devices: AppSectionState<ProviderOptionsDevice>;
servers: AppSectionState<FieldSelectOption<unknown>>;
newznabCategories: AppSectionState<FieldSelectOption<unknown>>;
getProfiles: AppSectionState<FieldSelectOption<unknown>>;
getTags: AppSectionState<FieldSelectOption<unknown>>;
getRootFolders: AppSectionState<FieldSelectOption<unknown>>;
}
export default ProviderOptionsAppState;

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import RootFolder from 'typings/RootFolder';
interface RootFolderAppState
extends AppSectionState<RootFolder>,
AppSectionDeleteState,
AppSectionSaveState {}
export default RootFolderAppState;

View File

@@ -1,66 +0,0 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Column from 'Components/Table/Column';
import { SortDirection } from 'Helpers/Props/sortDirections';
import Series from 'Series/Series';
import { Filter, FilterBuilderProp } from './AppState';
export interface SeriesIndexAppState {
sortKey: string;
sortDirection: SortDirection;
secondarySortKey: string;
secondarySortDirection: SortDirection;
view: string;
posterOptions: {
detailedProgressBar: boolean;
size: string;
showTitle: boolean;
showMonitored: boolean;
showQualityProfile: boolean;
showTags: boolean;
showSearchAction: boolean;
};
overviewOptions: {
detailedProgressBar: boolean;
size: string;
showMonitored: boolean;
showNetwork: boolean;
showQualityProfile: boolean;
showPreviousAiring: boolean;
showAdded: boolean;
showSeasonCount: boolean;
showPath: boolean;
showSizeOnDisk: boolean;
showTags: boolean;
showSearchAction: boolean;
};
tableOptions: {
showBanners: boolean;
showSearchAction: boolean;
};
selectedFilterKey: string;
filterBuilderProps: FilterBuilderProp<Series>[];
filters: Filter[];
columns: Column[];
}
interface SeriesAppState
extends AppSectionState<Series>,
AppSectionDeleteState,
AppSectionSaveState {
itemMap: Record<number, number>;
deleteOptions: {
addImportListExclusion: boolean;
};
pendingChanges: Partial<Series>;
}
export default SeriesAppState;

View File

@@ -1,13 +1,11 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionItemSchemaState,
AppSectionItemState,
AppSectionListState,
AppSectionSaveState,
AppSectionSchemaState,
PagedAppSectionState,
} from 'App/State/AppSectionState';
import Language from 'Language/Language';
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
import CustomFormat from 'typings/CustomFormat';
import CustomFormatSpecification from 'typings/CustomFormatSpecification';
@@ -16,21 +14,9 @@ import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import ImportListExclusion from 'typings/ImportListExclusion';
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
import Indexer from 'typings/Indexer';
import IndexerFlag from 'typings/IndexerFlag';
import Notification from 'typings/Notification';
import QualityDefinition from 'typings/QualityDefinition';
import QualityProfile from 'typings/QualityProfile';
import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
import General from 'typings/Settings/General';
import IndexerOptions from 'typings/Settings/IndexerOptions';
import MediaManagement from 'typings/Settings/MediaManagement';
import NamingConfig from 'typings/Settings/NamingConfig';
import NamingExample from 'typings/Settings/NamingExample';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import RemotePathMapping from 'typings/Settings/RemotePathMapping';
import UiSettings from 'typings/Settings/UiSettings';
import MetadataAppState from './MetadataAppState';
type Presets<T> = T & {
presets: T[];
@@ -64,20 +50,6 @@ export interface DownloadClientOptionsAppState
extends AppSectionItemState<DownloadClientOptions>,
AppSectionSaveState {}
export interface GeneralAppState
extends AppSectionItemState<General>,
AppSectionSaveState {}
export interface MediaManagementAppState
extends AppSectionItemState<MediaManagement>,
AppSectionSaveState {}
export interface NamingAppState
extends AppSectionItemState<NamingConfig>,
AppSectionSaveState {}
export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
export interface ImportListAppState
extends AppSectionState<ImportList>,
AppSectionDeleteState,
@@ -90,40 +62,6 @@ export interface IndexerOptionsAppState
extends AppSectionItemState<IndexerOptions>,
AppSectionSaveState {}
export interface IndexerAppState
extends AppSectionState<Indexer>,
AppSectionDeleteState,
AppSectionSaveState,
AppSectionSchemaState<Presets<Indexer>> {
isTestingAll: boolean;
}
export interface NotificationAppState
extends AppSectionState<Notification>,
AppSectionDeleteState,
AppSectionSaveState,
AppSectionSchemaState<Presets<Notification>> {}
export interface QualityDefinitionsAppState
extends AppSectionState<QualityDefinition>,
AppSectionSaveState {
pendingChanges: {
[key: number]: Partial<QualityProfile>;
};
}
export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionItemSchemaState<QualityProfile>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface ReleaseProfilesAppState
extends AppSectionState<ReleaseProfile>,
AppSectionSaveState {
pendingChanges: Partial<ReleaseProfile>;
}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
@@ -147,19 +85,9 @@ export interface ImportListExclusionsSettingsAppState
pendingChanges: Partial<ImportListExclusion>;
}
export interface RemotePathMappingsAppState
extends AppSectionState<RemotePathMapping>,
AppSectionDeleteState,
AppSectionSaveState {
pendingChanges: Partial<RemotePathMapping>;
}
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
export type LanguageSettingsAppState = AppSectionState<Language>;
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState {
advancedSettings: boolean;
autoTaggings: AutoTaggingAppState;
autoTaggingSpecifications: AutoTaggingSpecificationAppState;
customFormats: CustomFormatAppState;
@@ -167,24 +95,11 @@ interface SettingsAppState {
delayProfiles: DelayProfileAppState;
downloadClients: DownloadClientAppState;
downloadClientOptions: DownloadClientOptionsAppState;
general: GeneralAppState;
importListExclusions: ImportListExclusionsSettingsAppState;
importListOptions: ImportListOptionsSettingsAppState;
importLists: ImportListAppState;
indexerFlags: IndexerFlagSettingsAppState;
indexerOptions: IndexerOptionsAppState;
indexers: IndexerAppState;
languages: LanguageSettingsAppState;
mediaManagement: MediaManagementAppState;
metadata: MetadataAppState;
naming: NamingAppState;
namingExamples: NamingExamplesAppState;
notifications: NotificationAppState;
qualityDefinitions: QualityDefinitionsAppState;
qualityProfiles: QualityProfilesAppState;
releaseProfiles: ReleaseProfilesAppState;
remotePathMappings: RemotePathMappingsAppState;
ui: UiSettingsAppState;
}
export default SettingsAppState;

View File

@@ -1,25 +0,0 @@
import DiskSpace from 'typings/DiskSpace';
import Health from 'typings/Health';
import LogFile from 'typings/LogFile';
import SystemStatus from 'typings/SystemStatus';
import Task from 'typings/Task';
import AppSectionState, { AppSectionItemState } from './AppSectionState';
import BackupAppState from './BackupAppState';
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type TaskAppState = AppSectionState<Task>;
export type LogFilesAppState = AppSectionState<LogFile>;
interface SystemAppState {
backups: BackupAppState;
diskSpace: DiskSpaceAppState;
health: HealthAppState;
logFiles: LogFilesAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
updateLogFiles: LogFilesAppState;
}
export default SystemAppState;

View File

@@ -1,32 +0,0 @@
import ModelBase from 'App/ModelBase';
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
export interface Tag extends ModelBase {
label: string;
}
export interface TagDetail extends ModelBase {
label: string;
autoTagIds: number[];
delayProfileIds: number[];
downloadClientIds: [];
importListIds: number[];
indexerIds: number[];
notificationIds: number[];
restrictionIds: number[];
seriesIds: number[];
}
export interface TagDetailAppState
extends AppSectionState<TagDetail>,
AppSectionDeleteState,
AppSectionSaveState {}
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {
details: TagDetailAppState;
}
export default TagsAppState;

View File

@@ -1,29 +0,0 @@
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
interface WantedEpisode extends Episode {
isSaving?: boolean;
}
interface WantedCutoffUnmetAppState
extends AppSectionState<WantedEpisode>,
AppSectionFilterState<WantedEpisode>,
PagedAppSectionState,
TableAppSectionState {}
interface WantedMissingAppState
extends AppSectionState<WantedEpisode>,
AppSectionFilterState<WantedEpisode>,
PagedAppSectionState,
TableAppSectionState {}
interface WantedAppState {
cutoffUnmet: WantedCutoffUnmetAppState;
missing: WantedMissingAppState;
}
export default WantedAppState;

View File

@@ -0,0 +1,202 @@
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import getQueryPath from 'Utilities/Fetch/getQueryPath';
import fetchJson from 'Utilities/requestAction';
function getDimensions(width: number, height: number) {
const dimensions = {
width,
height,
isExtraSmallScreen: width <= 480,
isSmallScreen: width <= 768,
isMediumScreen: width <= 992,
isLargeScreen: width <= 1200,
};
return dimensions;
}
interface Dimensions {
width: number;
height: number;
isExtraSmallScreen: boolean;
isSmallScreen: boolean;
isMediumScreen: boolean;
isLargeScreen: boolean;
}
interface AppState {
dimensions: Dimensions;
version: string;
prevVersion?: string;
isUpdated: boolean;
isConnected: boolean;
isReconnecting: boolean;
isDisconnected: boolean;
isRestarting: boolean;
isSidebarVisible: boolean;
}
// Variables for ping functionality
let abortPingServer: (() => void) | null = null;
let pingTimeout: ReturnType<typeof setTimeout> | null = null;
const useAppStore = create<AppState>()(() => {
const dimensions = getDimensions(window.innerWidth, window.innerHeight);
return {
dimensions,
version: window.Sonarr.version,
isUpdated: false,
isConnected: true,
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
isSidebarVisible: !dimensions.isSmallScreen,
};
});
export const useAppValues = <K extends keyof AppState>(...keys: K[]) => {
return useAppStore(
useShallow((state) => {
return keys.reduce((acc, key) => {
acc[key] = state[key];
return acc;
}, {} as Pick<AppState, K>);
})
);
};
export const useAppValue = <K extends keyof AppState>(key: K) => {
return useAppStore(useShallow((state) => state[key]));
};
export const useAppDimensions = () => {
return useAppStore(useShallow((state) => state.dimensions));
};
export const useAppDimension = <K extends keyof Dimensions>(key: K) => {
return useAppStore(useShallow((state) => state.dimensions[key]));
};
export const getAppDimensions = () => {
return useAppStore.getState().dimensions;
};
export const getAppValues = <K extends keyof AppState>(...keys: K[]) => {
const state = useAppStore.getState();
return keys.reduce((acc, key) => {
acc[key] = state[key];
return acc;
}, {} as Pick<AppState, K>);
};
export const getAppValue = <K extends keyof AppState>(key: K) => {
return useAppStore.getState()[key];
};
function pingServerAfterTimeout() {
if (abortPingServer) {
abortPingServer();
abortPingServer = null;
}
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
pingTimeout = setTimeout(async () => {
const { isRestarting, isConnected } = getAppValues(
'isRestarting',
'isConnected'
);
if (!isRestarting && isConnected) {
return;
}
const abortController = new AbortController();
abortPingServer = () => abortController.abort();
try {
await fetchJson({
url: getQueryPath('/system/status'),
method: 'GET',
signal: abortController.signal,
headers: {
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
});
abortPingServer = null;
pingTimeout = null;
setAppValue({
isRestarting: false,
});
} catch (error: unknown) {
abortPingServer = null;
pingTimeout = null;
if ((error as { status?: number }).status === 401) {
setAppValue({
isRestarting: false,
});
} else if (!abortController.signal.aborted) {
pingServerAfterTimeout();
}
}
}, 5000);
}
export const saveDimensions = ({
width,
height,
}: {
width: number;
height: number;
}) => {
const dimensions = getDimensions(width, height);
useAppStore.setState({ dimensions });
};
export const setVersion = ({ version }: { version: string }) => {
useAppStore.setState((state) => {
const newState: Partial<AppState> = {
version,
};
if (state.version !== version) {
if (!state.prevVersion) {
newState.prevVersion = state.version;
}
newState.isUpdated = true;
}
return newState;
});
};
export const setIsSidebarVisible = ({
isSidebarVisible,
}: {
isSidebarVisible: boolean;
}) => {
useAppStore.setState({ isSidebarVisible });
};
export const toggleIsSidebarVisible = () => {
useAppStore.setState((state) => ({
isSidebarVisible: !state.isSidebarVisible,
}));
};
export const setAppValue = (payload: Partial<AppState>) => {
useAppStore.setState(payload);
};
export const pingServer = () => {
pingServerAfterTimeout();
};

View File

@@ -0,0 +1,56 @@
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import ModelBase from './ModelBase';
export type MessageType = 'error' | 'info' | 'success' | 'warning';
export interface Message extends ModelBase {
hideAfter: number;
message: string;
name: string;
type: MessageType;
}
interface MessagesState {
messages: Message[];
}
const useMessagesStore = create<MessagesState>()(() => ({
messages: [],
}));
export const useMessages = () => {
return useMessagesStore(useShallow((state) => state.messages));
};
export const getMessages = () => {
return useMessagesStore.getState().messages;
};
export const showMessage = (payload: Message) => {
useMessagesStore.setState((state) => {
const messages = [...state.messages];
const index = messages.findIndex((item) => item.id === payload.id);
if (index >= 0) {
const item = messages[index];
messages.splice(index, 1, { ...item, ...payload });
} else {
messages.push({ ...payload });
}
return {
messages,
};
});
};
export const hideMessage = ({ id }: { id: string | number }) => {
useMessagesStore.setState((state) => {
const messages = state.messages.filter((item) => item.id !== id);
return {
messages,
};
});
};

View File

@@ -0,0 +1,3 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient();

View File

@@ -0,0 +1,28 @@
import { useEffect } from 'react';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import { setTranslations } from 'Utilities/String/translate';
interface TranslationsResponse {
strings: Record<string, string>;
}
export function useTranslations() {
const { data, ...result } = useApiQuery<TranslationsResponse>({
path: '/localization',
queryOptions: {
staleTime: Infinity,
gcTime: Infinity,
},
});
useEffect(() => {
if (data) {
setTranslations(data.strings);
}
}, [data]);
return {
...result,
data,
};
}

View File

@@ -1,20 +1,19 @@
import moment from 'moment';
import React from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import useCalendar from 'Calendar/useCalendar';
import AgendaEvent from './AgendaEvent';
import styles from './Agenda.css';
function Agenda() {
const { items } = useSelector((state: AppState) => state.calendar);
const { data } = useCalendar();
return (
<div className={styles.agenda}>
{items.map((item, index) => {
{data.map((item, index) => {
const momentDate = moment(item.airDateUtc);
const showDate =
index === 0 ||
!moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
!moment(data[index - 1].airDateUtc).isSame(momentDate, 'day');
return <AgendaEvent key={item.id} showDate={showDate} {...item} />;
})}

View File

@@ -1,8 +1,7 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import { useCalendarOptions } from 'Calendar/calendarOptionsStore';
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
@@ -10,11 +9,11 @@ import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { useSingleSeries } from 'Series/useSeries';
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
@@ -55,24 +54,26 @@ function AgendaEvent(props: AgendaEventProps) {
showDate,
} = props;
const series = useSeries(seriesId)!;
const series = useSingleSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id));
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);
const queueItem = useQueueItemForEpisode(id);
const { timeFormat, longDateFormat, enableColorImpairedMode, timeZone } =
useUiSettingsValues();
const {
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
} = useSelector((state: AppState) => state.calendar.options);
} = useCalendarOptions();
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const startTime = convertToTimezone(airDateUtc, timeZone);
const endTime = convertToTimezone(airDateUtc, timeZone).add(
series.runtime,
'minutes'
);
const downloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(
@@ -110,9 +111,10 @@ function AgendaEvent(props: AgendaEventProps) {
)}
>
<div className={styles.time}>
{formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '}
{formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true,
timeZone,
})}
</div>

View File

@@ -1,97 +1,65 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import CommandNames from 'Commands/CommandNames';
import { useCommandExecuting } from 'Commands/useCommands';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Episode from 'Episode/Episode';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import {
clearCalendar,
fetchCalendar,
gotoCalendarToday,
} from 'Store/Actions/calendarActions';
import {
clearEpisodeFiles,
fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import Agenda from './Agenda/Agenda';
import { useCalendarOption } from './calendarOptionsStore';
import CalendarDays from './Day/CalendarDays';
import DaysOfWeek from './Day/DaysOfWeek';
import CalendarHeader from './Header/CalendarHeader';
import useCalendar, { goToToday } from './useCalendar';
import styles from './Calendar.css';
const UPDATE_DELAY = 3600000; // 1 hour
function Calendar() {
const dispatch = useDispatch();
const requestCurrentPage = useCurrentPage();
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const { isFetching, isPopulated, error, items, time, view } = useSelector(
(state: AppState) => state.calendar
);
const { isFetching, isLoading, error, refetch } = useCalendar();
const view = useCalendarOption('view');
const isRefreshingSeries = useSelector(
createCommandExecutingSelector(commandNames.REFRESH_SERIES)
);
const firstDayOfWeek = useSelector(
(state: AppState) => state.settings.ui.item.firstDayOfWeek
);
const isRefreshingSeries = useCommandExecuting(CommandNames.RefreshSeries);
const wasRefreshingSeries = usePrevious(isRefreshingSeries);
const previousFirstDayOfWeek = usePrevious(firstDayOfWeek);
const previousItems = usePrevious(items);
const handleScheduleUpdate = useCallback(() => {
clearTimeout(updateTimeout.current);
function updateCalendar() {
dispatch(gotoCalendarToday());
goToToday();
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
}
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
}, [dispatch]);
}, []);
useEffect(() => {
handleScheduleUpdate();
return () => {
dispatch(clearCalendar());
dispatch(clearQueueDetails());
dispatch(clearEpisodeFiles());
clearTimeout(updateTimeout.current);
};
}, [dispatch, handleScheduleUpdate]);
}, [handleScheduleUpdate]);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchCalendar());
} else {
dispatch(gotoCalendarToday());
if (!requestCurrentPage) {
goToToday();
}
}, [requestCurrentPage, dispatch]);
}, [requestCurrentPage]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchQueueDetails({ time, view }));
dispatch(fetchCalendar({ time, view }));
refetch();
};
registerPagePopulator(repopulate, [
@@ -102,61 +70,31 @@ function Calendar() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [time, view, dispatch]);
}, [refetch]);
useEffect(() => {
handleScheduleUpdate();
}, [time, handleScheduleUpdate]);
useEffect(() => {
if (
previousFirstDayOfWeek != null &&
firstDayOfWeek !== previousFirstDayOfWeek
) {
dispatch(fetchCalendar({ time, view }));
}
}, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]);
}, [handleScheduleUpdate]);
useEffect(() => {
if (wasRefreshingSeries && !isRefreshingSeries) {
dispatch(fetchCalendar({ time, view }));
refetch();
}
}, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]);
useEffect(() => {
if (!previousItems || hasDifferentItems(items, previousItems)) {
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
const episodeFileIds = selectUniqueIds<Episode, number>(
items,
'episodeFileId'
);
if (items.length) {
dispatch(fetchQueueDetails({ episodeIds }));
}
if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds }));
}
}
}, [items, previousItems, dispatch]);
}, [isRefreshingSeries, wasRefreshingSeries, refetch]);
return (
<div className={styles.calendar}>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isLoading ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
) : null}
{!error && isPopulated && view === 'agenda' ? (
{!error && !isLoading && view === 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />
<Agenda />
</div>
) : null}
{!error && isPopulated && view !== 'agenda' ? (
{!error && !isLoading && view !== 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />
<DaysOfWeek />

View File

@@ -1,49 +1,24 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import { SetFilter } from 'Components/Filter/Filter';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setCalendarFilter } from 'Store/Actions/calendarActions';
function createCalendarSelector() {
return createSelector(
(state: AppState) => state.calendar.items,
(calendar) => {
return calendar;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.calendar.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
import { setCalendarOption } from './calendarOptionsStore';
import useCalendar, { FILTER_BUILDER } from './useCalendar';
type CalendarFilterModalProps = FilterModalProps<History>;
export default function CalendarFilterModal(props: CalendarFilterModalProps) {
const sectionItems = useSelector(createCalendarSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const { data } = useCalendar();
const customFilterType = 'calendar';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setCalendarFilter(payload));
},
[dispatch]
);
const dispatchSetFilter = useCallback(({ selectedFilterKey }: SetFilter) => {
setCalendarOption('selectedFilterKey', selectedFilterKey);
}, []);
return (
<FilterModal
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
sectionItems={data}
filterBuilderProps={FILTER_BUILDER}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>

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