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

Compare commits

...

475 Commits

Author SHA1 Message Date
Mark McDowall
d0101bdca1 Don't publish v5 builds yet 2025-02-01 11:58:31 -08:00
Mark McDowall
539f55deae Fix tests for v5 2025-02-01 11:58:31 -08:00
Mark McDowall
0f90544a35 Improve build speed 2025-02-01 11:58:31 -08:00
Mark McDowall
f10b0e51b5 Fix builds with v5 API 2025-01-30 21:15:48 -08:00
Mark McDowall
b5c54575c2 Fix import order after TS 2025-01-29 20:11:50 -08:00
Mark McDowall
f89ee64edc Build and conflict labeler for PRs against v5-develop 2025-01-29 20:11:50 -08:00
Mark McDowall
59800d3675 v5 API docs 2025-01-29 20:11:50 -08:00
Mark McDowall
ed1b17005f Add v5 API 2025-01-26 08:13:54 -08:00
Mark McDowall
ea5231abef Fix signalR 2025-01-25 19:38:34 -08:00
Steel City Phantom
42206b023f Auto-detect building on macOS ARM 2025-01-25 19:38:34 -08:00
Stevie Robinson
bf55bca142 New: Show size in history details
Closes #7594
2025-01-25 19:38:09 -08:00
Bogdan
496eb6fd37 Typing for Interactive Search payload 2025-01-25 19:38:08 -08:00
Bogdan
1a5fa185d1 New: Migrate appdata folder for .NET 8 on OSX 2025-01-25 19:38:08 -08:00
Bogdan
518f1799dc New: Bump to .NET 8 2025-01-25 19:38:08 -08:00
Mark McDowall
9f570d4dbf Convert signalR to TypeScript 2025-01-25 19:38:08 -08:00
Mark McDowall
dd014993f1 Convert Import Series to TypeScript 2025-01-25 19:38:08 -08:00
Mark McDowall
61876e795f Remove pnpm-lock.yaml 2025-01-25 19:38:07 -08:00
Mark McDowall
e721a06291 Remove defaultProps from SeriesIndexFilterMenu 2025-01-25 19:38:07 -08:00
Mark McDowall
37cc66ce66 Convert Add New Series to TypeScript 2025-01-25 19:38:07 -08:00
Mark McDowall
9c196c5fa0 Remove Measure 2025-01-25 19:38:07 -08:00
Mark McDowall
7dcf0808dc Convert Series Details to TypeScript 2025-01-25 19:38:07 -08:00
Mark McDowall
46afe84edc Convert Delete Series Modal to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
0ff3101511 Convert Series History to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
17aab235a5 Fixed Import List CleanLibraryLevel Options 2025-01-25 19:38:06 -08:00
Mark McDowall
f1cfef19b2 Convert Monitoring Options to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
d89d1b2f8b Convert MoveSeriesModal to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
f986062d4c Convert FilterBuilder types to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
e2bc322462 Convert Date utilties to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
8e2263d1a1 Convert TableOptionsWrapper to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
307135d3f0 Convert Series Popovers to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
0b58278e15 Convert Table to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
d8a147d234 Convert Messages to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
bba2ab98b6 Remove withCurrentPage 2025-01-25 19:38:04 -08:00
Mark McDowall
4852afcad7 Convert Missing to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
0cb656cdd8 Convert Cutoff Unmet to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
4a5b839d93 Convert Custom Format settings to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
65bae6a7ce Convert Notifications to TypeScript 2025-01-25 19:38:03 -08:00
Mark McDowall
8b0a1b7756 Convert Download Client settings to TypeScript 2025-01-25 19:38:03 -08:00
Mark McDowall
aad8ba0f9b Improve typings in FormInputGroup 2025-01-25 19:38:03 -08:00
Mark McDowall
ebb0aab2f5 Convert General Settings to TypeScript 2025-01-25 19:38:03 -08:00
Mark McDowall
e62c687d93 Convert ImportLists to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
ac2ecae874 Convert Indexer settings to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
3991eec5e0 Convert Media Management settings to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
15e4599d31 Convert MetadataSource to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
9f1b0d3a3b Upgrade react-dnd and DnD Components to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
64c1ef85c4 New: Quality limits are part of Quality Profile
Closes #613
2025-01-25 19:38:01 -08:00
Mark McDowall
9ce473d9bb Convert Quality Settings to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
89f584d1b3 Convert Tags to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
405ee7473c Convert MetadataSettings to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
e9f8023528 Convert UI Settings to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
86c785ffa0 Convert SettingsToolbar to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
64160866c3 Convert Log FIles to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
1e5932d89a Convert Log Events to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
9276bd7a16 Convert Backup and Restore to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
8a8ea4eb94 Convert Preview Rename to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
6bee95747e Convert SelectSeriesRow to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
dc576d0dd3 Convert TagList components to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
5dfb5de863 Convert Menu components to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
ac7ac34cc2 Convert ProviderFieldFormGroup to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
24173139f0 Remove defaultProps from TypeScript components 2025-01-25 19:37:59 -08:00
Mark McDowall
3cd8a2a98b Convert Filter components to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
e4f1b2c4ec Convert Spinner button components to TypeScript 2025-01-25 19:37:58 -08:00
Mark McDowall
442b3b506f Convert Modal components to TypeScript 2025-01-25 19:37:58 -08:00
Mark McDowall
e8a6ce371d useMeasure instead of Measure in TypeScript components 2025-01-25 19:37:58 -08:00
Mark McDowall
d9e5842f8b Convert Page components to TypeScript 2025-01-25 19:37:58 -08:00
Stevie Robinson
e37684e045 Fixed: Failing dangerous and executable single file downloads 2025-01-25 18:29:07 -08:00
Stevie Robinson
103ccd74f3 New: Treat .scr as dangerous file
Closes #7588
2025-01-25 18:27:32 -08:00
Stevie Robinson
ba22992265 Fixed: Don't search for unmonitored specials when searching season
Closes #7589
2025-01-25 18:26:48 -08:00
Bogdan
963395b969 Prevent page crash on console.error being used with non-string values 2025-01-25 18:26:10 -08:00
Weblate
970df1a1d8 Multiple Translations updated by Weblate
ignore-downstream

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

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

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

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

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

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

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

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

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-12-16 20:57:20 -08:00
Bogdan
5d1d44e09e New: Series genres for search results 2024-12-14 18:51:58 -08:00
Bogdan
3b00112447 Fixed: Refresh backup list on deletion 2024-12-14 18:51:49 -08:00
Mark McDowall
cb7489ce8f Fixed: Augmenting languages from indexer for release with stale indexer ID
Closes #7476
2024-12-14 18:51:40 -08:00
Mark McDowall
b552d4e9f7 Fixed: Error getting processes in some cases
Closes #7470
2024-12-14 18:51:31 -08:00
Mark McDowall
c0e264cfc5 Fixed: Series without tags bypassing tags on Download Client
Closes #7474
2024-12-14 18:51:19 -08:00
Mark McDowall
811eb36c7b Convert Calendar to TypeScript 2024-12-14 18:51:10 -08:00
Mark McDowall
1484809099 Upgrade TypeScript and core-js 2024-12-14 18:51:10 -08:00
Bogdan
024462c52d Fixed: Fetching ICS calendar with missing series 2024-12-14 18:51:04 -08:00
Weblate
e70aef9690 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Tomer Horowitz <tomerh2001@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: hhjuhl <hans@kopula.dk>
Co-authored-by: kaisernet <afimark7@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/da/
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/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-12-14 18:50:57 -08:00
Stevie Robinson
36633b5d08 New: Optionally as Instance Name to Telegram notifications
Closes #7391
2024-12-08 19:37:51 -08:00
Mark McDowall
1374240321 Fixed: Converting TimeSpan from database
Closes #7461
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2024-12-08 19:36:58 -08:00
Mark McDowall
f1d54d2a9a Convert EpisodeHistory to TypeScript 2024-12-08 19:36:51 -08:00
Mark McDowall
03b8c4c28e Convert EpisodeSearch to TypeScript 2024-12-08 19:36:51 -08:00
Mark McDowall
4e4bf3507f Convert MediaInfo to TypeScript 2024-12-08 19:36:51 -08:00
Stevie Robinson
34ae65c087 Refine localization string for IndexerSettingsFailDownloadsHelpText 2024-12-08 19:36:42 -08:00
Mark McDowall
ebe23104d4 Fixed: Custom Format score bypassing upgrades not being allowed 2024-12-08 19:36:23 -08:00
Stevie Robinson
e8c3aa20bd New: Reactive search button on Wanted pages
Closes #7449
2024-12-08 19:36:10 -08:00
Bogdan
6c231cbe6a Increase input sizes in edit series modal 2024-12-08 19:35:41 -08:00
Bogdan
8ce688186e Cleanup unused metadatas connector 2024-12-08 19:35:41 -08:00
Weblate
04ebf03fb5 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Michaa85 <michael.seipel@gmx.de>
Co-authored-by: Rodion <rodyon009@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: farebyting <farelbyting@gmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: keysuck <joshkkim@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/id/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/uk/
Translation: Servarr/Sonarr
2024-12-08 19:35:31 -08:00
Weblate
c38debab1b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.com>
Co-authored-by: mryx007 <mryx@mail.de>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-12-01 16:24:11 -08:00
Sonarr
32f66922e7 Automated API Docs update
ignore-downstream
2024-12-01 16:23:18 -08:00
Gylesie
ed536a85ad Remove unnecessary heap allocations in local IP check 2024-12-01 16:22:04 -08:00
Mark McDowall
c62fc9d05b New: Kometa metadata file creation disabled
Closes #7400
2024-12-01 16:20:55 -08:00
Mark McDowall
fb9a5efe05 Add return type for series/lookup endpoint
Closes #7438
2024-12-01 16:20:19 -08:00
Mark McDowall
8cb58a63d8 Fixed: Don't fail import if symlink target can't be resolved
Closes #7431
2024-12-01 16:20:19 -08:00
soup
4c41a4f368 New: Add config file setting for CGNAT authentication bypass 2024-12-01 16:20:08 -08:00
Stevie Robinson
e039dc45e2 New: Add Languages to Webhook Notifications
Closes #7421
2024-12-01 16:16:36 -08:00
Mark McDowall
776143cc81 New: Option to treat downloads with non-media extensions as failed
Closes #7369
2024-12-01 16:15:52 -08:00
Mark McDowall
8c67a3bdee Add reason enum to decision engine rejections 2024-12-01 16:15:52 -08:00
hhjuhl
160151c6e0 Use 'text-wrap: balance' for text wrapping on overview and details
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2024-12-01 16:15:33 -08:00
Robin Dadswell
efd48710e4 Deleted translation using Weblate (zh_HANS (generated) (zh_HANS)) 2024-12-01 16:14:37 -08:00
Weblate
00c16cd06b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Albrt9527 <2563009889@qq.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: mryx007 <mryx@mail.de>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-12-01 16:14:37 -08:00
Sonarr
65d07fa99e Automated API Docs update
ignore-downstream
2024-11-27 17:32:51 -08:00
Bogdan
bd656ae7f6 Fixed: Avoid default category on existing Transmission configurations
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2024-11-27 17:30:03 -08:00
Mark McDowall
62bcf397dd Fixed: Adding/Editing not replacing Implementation Name 2024-11-27 17:29:24 -08:00
Mark McDowall
f9606518ee Fixed: Error loading queue
Closes #7422
2024-11-27 17:29:24 -08:00
Mark McDowall
40f4ef27b2 Support Postgres with non-standard version string 2024-11-26 17:37:37 -08:00
Mark McDowall
93c3f6d1d6 Fixed: Truncating long text in the middle when it shouldn't be truncated
Closes #7413
2024-11-26 17:37:30 -08:00
Mark McDowall
417af2b915 New: Ability to change root folder when editing series
Closes #5544
2024-11-26 17:37:21 -08:00
Mark McDowall
4491df3ae7 Update React and add React Query 2024-11-26 17:37:21 -08:00
Mark McDowall
a90866a73e Webpack web target 2024-11-26 17:37:21 -08:00
Mark McDowall
2f62494adc Convert EditSeriesModal to TypeScript 2024-11-26 17:37:21 -08:00
Mark McDowall
e361f18837 New: Support for new SABnzbd history retention values
Closes #7373
2024-11-26 17:37:06 -08:00
Mark McDowall
183b8b574a Deluge communication improvements
Closes #7318
2024-11-26 17:36:53 -08:00
Mark McDowall
12c1eb86f2 Fixed: New episodes in season follow season's monitored status
Closes #7401
2024-11-26 17:36:26 -08:00
Mark McDowall
5034d83062 Fixed: Kometa and Kodi metadata failing with duplicate episode files
Closes #7381
2024-11-26 17:36:10 -08:00
Mark McDowall
dba3a82439 Fixed: Prevent lack of internet from stopping all health checks from running 2024-11-26 17:36:00 -08:00
Mark McDowall
b51a490979 Rename SizeLeft and TimeLeft queue item properties
Closes #7392
2024-11-26 17:36:00 -08:00
Mark McDowall
8b38ccfb63 Bump version to 4.0.11 2024-11-26 17:11:56 -08:00
Mark McDowall
91c5e6f122 Fixed: Custom Format upgrading not respecting 'Upgrades Allowed' 2024-11-26 17:09:12 -08:00
Weblate
dcbef6b7b7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: 4kwins <hanszimmerme@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Mizuyoru_TW <mizuyoru.tw@gmail.com>
Co-authored-by: Stanislav <stasstrochewskij@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/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/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_TW/
Translation: Servarr/Sonarr
2024-11-26 17:09:02 -08:00
Elias Benbourenane
ca0bb14027 Allow GetFileSize to follow symlinks 2024-11-14 19:27:56 -08:00
Mark McDowall
3e99917e9d Fixed: Closing on click outside select input and styling on Library Import 2024-11-14 19:27:31 -08:00
Mark McDowall
936cf699ff Improve LanguageSelectInput 2024-11-14 19:27:31 -08:00
Mark McDowall
202190d032 New: Replace 'Ben the Man' release group parsing with 'Ben the Men'
Closes #7365
2024-11-14 19:02:09 -08:00
Mark McDowall
f739fd0900 Fixed: Allow files to be moved from Torrent Blackhole even when remove is disabled 2024-11-14 19:01:38 -08:00
Mark McDowall
88f4016fe0 New: Parse original from release name when specified
Closes #5805
2024-11-14 19:01:17 -08:00
Gauthier
78fb20282d New: Add headers setting in webhook connection 2024-11-14 19:01:05 -08:00
Mark McDowall
6677fd1116 New: Improve stored UI settings for multiple instances under the same host
Closes #7368
2024-11-14 19:00:21 -08:00
Mark McDowall
e28b7c3df6 Fixed: .plexmatch episodes on separate lines
Closes #7362
2024-11-14 19:00:10 -08:00
Bogdan
67a1ecb0fe Console warnings for missing translations on development builds 2024-11-14 18:59:53 -08:00
Mark McDowall
5bc943583c Don't try to process items that didn't import in manual import 2024-11-14 18:59:43 -08:00
Mark McDowall
ceeec091f8 Fixed: Normalize unicode characters when comparing paths for equality
Closes #6657
2024-11-14 18:59:43 -08:00
Bogdan
675e3cd38a New: Labels support for Transmission 4.0
Closes #7300
2024-11-14 18:59:25 -08:00
Weblate
45a62a2e59 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: 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/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-11-14 18:58:37 -08:00
Sonarr
ae7c07e02f Automated API Docs update
ignore-downstream
2024-11-07 22:42:05 -08:00
Weblate
4e9ef57e3d Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: mytelegrambot <lacsonluxur@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ko/
Translation: Servarr/Sonarr
2024-11-03 20:54:14 -08:00
Bogdan
59f3be0813 Show a series path as example in Mount Health Check 2024-11-03 20:53:48 -08:00
Mark McDowall
fb540040ef New: Filter queue by status
Closes #7196
2024-11-03 20:53:38 -08:00
Mark McDowall
b8af3af9f1 Fixed: Filtering queue by multiple qualities 2024-11-03 20:53:38 -08:00
Mark McDowall
78cf13d341 Increase retries for DebouncerFixture 2024-11-03 20:53:23 -08:00
Mark McDowall
978349e241 New: Reject files during import that have no audio tracks
Closes #7298
2024-11-03 20:53:23 -08:00
Mark McDowall
a77bf64352 New: Monitor New Seasons column for series list
Closes #7311
2024-11-03 20:53:14 -08:00
Bogdan
832de3e75e Fixed: Root folder existence for import lists health check 2024-11-03 20:53:05 -08:00
Bogdan
8d4ba77b12 Fixed: New values for custom filters 2024-11-03 20:52:47 -08:00
Bogdan
409823c7e8 Fixed: Interactive searches when using Escape to close previous searches 2024-11-03 20:51:31 -08:00
Aviad Levy
8e636d7a37 Fixed: Telegram notification link text 2024-11-03 20:51:19 -08:00
Bogdan
38c0135d7c Fixed: Loading queue with pending releases for deleted series 2024-11-03 20:49:55 -08:00
Bogdan
22005dc8c5 Fixed: Cleaning the French preposition 'à' from titles 2024-11-03 20:49:22 -08:00
Mark McDowall
73208e2f60 New: Include source path with Webhook import event episode file 2024-11-03 20:49:12 -08:00
Mark McDowall
1df0ba9e5a Fixed: Use download client name for history column 2024-11-03 20:49:03 -08:00
Mark McDowall
020ed32fcf Use current time for cache break in development 2024-11-03 20:48:56 -08:00
Mark McDowall
3ddc6ac6de New: Favorite folders in Manual Import
Closes #5891
2024-11-03 20:48:56 -08:00
Mark McDowall
0f225b05c0 Rename Manage Custom Formats to Manage Formats 2024-11-03 20:48:42 -08:00
Mark McDowall
e006b40532 New: Add individual edit to Manage Custom Formats
Closes #5905
2024-11-03 20:48:42 -08:00
Mark McDowall
e88f25d3bf Fixed: Parse version after quality in renamed files
Closes #7302
2024-11-03 20:48:29 -08:00
Mark McDowall
1fcfb88d2a New: Use instance name in PWA manifest
Closes #7315
2024-11-03 20:48:16 -08:00
Weblate
804eaa1227 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Lars <lars.erik.heloe@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: http://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translation: Servarr/Sonarr
2024-11-03 20:48:10 -08:00
Bogdan
c41e3ce1e3 Update paths mapping translations for series specific 2024-10-26 14:54:40 -07:00
Mark McDowall
682d2b4e1b Convert Form Components to TypeScript 2024-10-26 14:54:23 -07:00
Weblate
c114e2ddb7 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
2024-10-26 14:54:07 -07:00
BarbUk
f8a879f4c1 Update System.Text.Json to version 6.0.10 2024-10-26 14:22:31 -07:00
Bogdan
33139d4b53 Fixed: Status check for completed directories in Deluge 2024-10-26 14:22:16 -07:00
Mark McDowall
de69d8ec7e Update JetBrains logos 2024-10-26 14:21:06 -07:00
Mark McDowall
03b9c957b8 New: Episode mappings in .plexmatch metadata files
Closes #5784
2024-10-26 14:20:55 -07:00
Mark McDowall
41ddacc395 New: Improve parsing absolute followed by standard numbering
Closes #7246
2024-10-26 14:20:41 -07:00
Mark McDowall
8a558b379a New: Maintain '...' in naming format
Closes #7290
2024-10-26 14:20:19 -07:00
Bogdan
240a0339be Fixed: Changing series to another root folder without moving files 2024-10-26 14:20:11 -07:00
Bogdan
ff724b7f40 Fixed: Initial state for qBittorrent v5.0 2024-10-26 14:19:47 -07:00
Bogdan
fcf68d9259 Fix settings fetching failure for updates 2024-10-26 14:19:19 -07:00
Bogdan
404e6d68ea Cleanse exceptions in event logs 2024-10-26 14:18:46 -07:00
Bogdan
df672487cf Improve message for grab errors due to no matching tags
Co-authored-by: zakary <zak@ary.dev>
2024-10-26 14:18:38 -07:00
Bogdan
0bc4903954 Inherit trigger from pushed command models 2024-10-26 14:18:28 -07:00
Bogdan
10b55bbee6 Fixed: Natural sorting for tags list in the UI
Closes #7295
2024-10-26 14:18:11 -07:00
Bogdan
20ef22be94 New: Real time UI updates for provider changes 2024-10-26 14:17:46 -07:00
Bogdan
57534db2f8 New: Display tags on import list cards 2024-10-26 14:17:18 -07:00
Bogdan
1e89a1a3cb Include exception message in SkyHook failure message 2024-10-26 14:15:53 -07:00
Bogdan
f502eaffe3 Bump frontend packages 2024-10-26 14:15:42 -07:00
Bogdan
fe40d83aa4 Fixed: Dedupe releases for single daily and anime episode searches
Closes #7288
2024-10-26 14:15:27 -07:00
Bogdan
07374de747 Fixed: Matched alternative titles and tags in series search results 2024-10-26 14:14:58 -07:00
Hadrien Patte
135b5c2ddd Use OperatingSystem class to get OS information 2024-10-26 14:14:20 -07:00
Weblate
0784f56b9a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.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/zh_CN/
Translation: Servarr/Sonarr
2024-10-26 14:14:05 -07:00
Mark McDowall
562e0dd7c0 Bump version to 4.0.10 2024-10-25 17:33:17 -07:00
Weblate
28599f87af Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: DNArjen <dna.visser@gmail.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: JoseFilipeFerreira <jose.filipe.matos.ferreira@gmail.com>
Co-authored-by: Kuzmich <kuzmich55@gmail.com>
Co-authored-by: Lars <lars.erik.heloe@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: anne <gagatebis@hotmail.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/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nl/
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/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translation: Servarr/Sonarr
2024-10-25 17:33:09 -07:00
Sonarr
86446a7686 Automated API Docs update
ignore-downstream
2024-10-07 15:36:01 -07:00
Bogdan
2f1793d87a Filename examples specific for daily and anime naming 2024-10-07 15:35:54 -07:00
Bogdan
a641f2897a Convert Naming options to TypeScript 2024-10-07 15:35:54 -07:00
Bogdan
32fa63d24d Convert FormInputButton to TypeScript 2024-10-07 15:35:54 -07:00
Weblate
ebfa000375 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
2024-10-07 15:31:53 -07:00
Mark McDowall
39074b0b1d New: Use 307 redirect for requests missing URL Base
Closes #7262
2024-10-07 15:29:59 -07:00
Mark McDowall
354ed96572 Fixed: Ignore free space check before grabbing if directory is missing
Closes #7273
2024-10-07 15:29:51 -07:00
Bogdan
c8f419b014 Use the first allowed quality for cutoff met rejection message with disabled upgrades 2024-10-07 15:29:44 -07:00
Bogdan
a001216957 Fixed: Cleaning paths for top level root folders 2024-10-07 18:29:30 -04:00
Bogdan
a6735e7a3f Fixed: Manual importing to nested series folders 2024-10-07 15:28:47 -07:00
Bogdan
ea0bfed700 Fixed: Validate path on series update 2024-10-07 15:27:53 -07:00
Bogdan
620220b269 Add new category for FL 2024-10-07 15:27:43 -07:00
Bogdan
c435fcd685 Fixed: Error updating providers with ID missing from JSON 2024-10-07 18:27:22 -04:00
Bogdan
3828e475cc Fixed: Copy to clipboard in non-secure contexts 2024-10-07 15:26:21 -07:00
Bogdan
e6e1078c15 Convert Release Profiles to TypeScript 2024-10-07 15:26:13 -07:00
Jared Ledvina
6660db22ec Recompare file size after import file if necessary 2024-10-07 18:25:52 -04:00
Weblate
bc0fc623ee Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.com>
Co-authored-by: Mathias <mathias@rodilbach.dk>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: angelsky11 <angelsky11@gmail.com>
Co-authored-by: jsain <josip.sain@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-10-07 15:24:30 -07:00
Mark McDowall
da610a1f40 New: Parse 'BEN THE MAN' release group
Closes #7255
2024-09-27 17:27:50 -07:00
Mark McDowall
6d0f10b877 Fixed: Ignore extra spaces in path when not running on Windows
Closes #7251
2024-09-27 17:27:37 -07:00
Mark McDowall
4f0e1c54c1 Fixed: Don't reject revision upgrades if profile doesn't allow upgrades 2024-09-27 17:27:27 -07:00
Bogdan
2f0ca42341 New: Ignore '.DS_Store' and '.unmanic' files 2024-09-27 20:27:17 -04:00
Bogdan
768af433d1 Display naming example errors when all fields are empty 2024-09-27 17:26:47 -07:00
Bogdan
8bf0298227 Fix translation for Custom Colon Replacement label 2024-09-27 17:26:47 -07:00
Robin Dadswell
a7cb264cc8 Fixed: Telegram log message including token 2024-09-27 20:26:29 -04:00
Bogdan
10302323af Fixed: Parsing of Hybrid-Remux as Remux 2024-09-27 20:26:04 -04:00
Mark McDowall
dc1524c64f Fixed: Loading series images after placeholder in Safari 2024-09-27 17:25:30 -07:00
Mark McDowall
4d7a3d0909 New: Errors sending Telegram notifications when links aren't available
Closes #7240
2024-09-27 17:25:21 -07:00
Bogdan
30a52d11aa Fixed: Sorting queue by columns
Sort allowed keys

Co-authored-by: Mark McDowall <markus.mcd5@gmail.com>
2024-09-27 17:25:14 -07:00
Weblate
be4a9e9491 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: liuwqq <843384478@qq.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-09-27 17:25:02 -07:00
Sonarr
e196c1be69 Automated API Docs update
ignore-downstream
2024-09-21 10:32:16 -07:00
Mark McDowall
106ffd410c New: Persist sort in Select Episodes modal
Closes #7233
2024-09-21 10:17:09 -07:00
Mark McDowall
c199fd05d3 Fixed: Don't set last write time on episode files if difference is within the same second
Closes #7228
2024-09-21 10:16:59 -07:00
Mark McDowall
75fae9262c Update src/Sonarr.Http/Authentication/AuthenticationBuilderExtensions.cs
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2024-09-21 10:16:52 -07:00
Mark McDowall
faf9173b3b Fixed: Unable to login when instance name contained brackets
Closes #7229
2024-09-21 10:16:52 -07:00
Bogdan
0fa8e24f48 New: Fetch up to 1000 series from Plex Watchlist 2024-09-21 10:16:43 -07:00
Mark McDowall
27da041388 Fixed: Reprocessing manual import items unable to detect sample
Closes #7221
2024-09-21 10:16:24 -07:00
Mark McDowall
ca38a9b577 Fixed: Aggregating media files with 576p resolution 2024-09-21 10:16:17 -07:00
Mark McDowall
4b72a0a4e8 Fixed: Rejections for Custom Format score increment 2024-09-21 10:16:17 -07:00
Mark McDowall
9875e550a8 Fixed: Adding Bluray 576p to some profiles 2024-09-21 10:16:17 -07:00
ManiMatter
c9aa59340c Add 'includeSeries' and 'includeEpisodeFile' to Episode API endpoint 2024-09-21 13:16:05 -04:00
momo
30c36fdc3b Fix description for API key as query parameter 2024-09-21 13:15:51 -04:00
Mark McDowall
3976e5daf7 Fixed: Interactive searches causing multiple requests to indexers 2024-09-21 10:12:13 -07:00
Bogdan
fca8c36156 Guard against using invalid sort keys 2024-09-21 13:12:01 -04:00
Stevie Robinson
85f53e8cb1 New: Parse KCRT as release group
Closes #7214
2024-09-21 13:10:44 -04:00
Weblate
a73a5cc85c Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: FloatStream <1213193613@qq.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/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-09-21 10:10:20 -07:00
Mark McDowall
89d730cdfd Fixed: Links for Trakt and TVMaze in Gotify notifications 2024-09-21 10:10:03 -07:00
Treycos
99fc52039f Convert ClipboardButton to TypeScript 2024-09-21 13:09:55 -04:00
Weblate
e6bd58453a 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
2024-09-15 10:26:43 -07:00
Sonarr
9603f0b086 Automated API Docs update
ignore-downstream
2024-09-15 10:23:52 -07:00
Mark McDowall
d84c450094 New: Add exception to SSL Certificate validation message
Closes #7198
2024-09-15 10:23:29 -07:00
Mark McDowall
97ebaf2796 New: Use instance name in forms authentication cookie name
Closes #7199
2024-09-15 10:23:22 -07:00
Stevie Robinson
31bf9e313e New: Add rating as option in sort dropdown on series overviews and posters views 2024-09-15 13:23:12 -04:00
Stevie Robinson
6cccacd4d7 Add workflow to close issue when labelled as support 2024-09-15 13:22:28 -04:00
Mark McDowall
3c857135c5 Gotify notification updates
New: Option to include links for Gotify notifications
New: Include images and links for Android
Closes #7190
2024-09-15 10:21:26 -07:00
Mark McDowall
750a9353f8 New: Add additional archive exentions
Closes #7191
2024-09-15 10:21:16 -07:00
Mark McDowall
71a19377d9 New: Add Bluray 576p quality
Closes #6203
2024-09-15 13:21:01 -04:00
Mark McDowall
4b5ff3927d New: Check for available space before grabbing
Closes #7177
2024-09-15 13:20:42 -04:00
Mark McDowall
4d8a443681 Fixed: Replace illegal characters even when renaming is disabled
Closes #7183
2024-09-15 10:20:19 -07:00
Bogdan
6a332b40ac Fixed: Refresh tags after updating autotags 2024-09-15 10:20:19 -07:00
Bogdan
a929548ae3 Fixed: Linking autotags with tag specification to all tags 2024-09-15 10:20:19 -07:00
Mark McDowall
55363f4e3d Fixed: Don't parse language from series title for v2 releases
Closes #7182
2024-09-15 10:20:19 -07:00
Mark McDowall
f20ac9dc34 Fixed: Series links not opening on iOS 2024-09-15 10:20:13 -07:00
somniumV
8b20a9449c New: Minimum Upgrade Score for Custom Formats
Closes #6800
2024-09-15 13:20:03 -04:00
Robert Dailey
24f03fc1e9 Add 'qualitydefinition/limits' endpoint to get size limitations 2024-09-15 13:19:08 -04:00
Weblate
5513d7bc5d Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: FloatStream <1213193613@qq.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Kuzmich55 <kuzmich55@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: genoher <genoher@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/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-09-15 10:17:38 -07:00
Mark McDowall
a9072ac460 Convert Progress Bars to TypeScript 2024-09-03 20:19:47 -07:00
Mark McDowall
55aaaa5c40 New: Add MDBList link to series details
Closes #7162
2024-09-03 23:19:36 -04:00
Mark McDowall
ee99c3895d Convert series images to TypeScript 2024-09-03 20:19:12 -07:00
Mark McDowall
e1e10e195c Convert NoSeries to TypeScript 2024-09-03 20:19:12 -07:00
Mark McDowall
0b9a212f33 Fixed: Links tooltip closing too quickly 2024-09-03 20:19:12 -07:00
Mark McDowall
0e384ee3aa New: Include seasons and episodes in Trakt import lists
Closes #7137
2024-09-03 20:18:59 -07:00
Sonarr
d903529389 Automated API Docs update
ignore-downstream
2024-09-02 13:27:43 -07:00
Mark McDowall
6f51e72d00 Fixed: Respect Quality cutoff if Custom Format cutoff isn't met
Closes #7132
2024-09-02 13:27:21 -07:00
Bogdan
66cead6b48 Cleanup History Details and a typo 2024-09-02 13:27:00 -07:00
Mark McDowall
7f0696c574 Fixed: Failing to import any file for series if one has bad encoding
Closes #7157
2024-09-02 13:26:50 -07:00
Mark McDowall
1584311914 New: Except language option for Language Custom Formats
Closes #7120
2024-09-02 13:26:35 -07:00
amdavie
278c7891a3 New: Scene and Nuked IndexerFlags for Newznab indexers
Closes #6932
2024-09-02 13:25:53 -07:00
Bogdan
0a0e03dca0 Convert Interactive Search to TypeScript 2024-09-02 13:25:05 -07:00
ManiMatter
546e9fd1d0 New: Last Searched column on Wanted screens 2024-09-02 13:24:55 -07:00
Weblate
c80bd81bb9 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Nota Inutilis <hugo@notainutilis.fr>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-09-02 13:24:04 -07:00
Mark McDowall
e1cbc4a782 Convert Components to TypeScript 2024-08-30 20:26:38 -07:00
Bogdan
53d8c9ba8d Fixed: Importing files without media info available 2024-08-30 20:26:22 -07:00
Bogdan
9136ee4ad9 Fixed: Forbid empty spaces in Release Profile restrictions 2024-08-30 23:25:32 -04:00
Bogdan
44fab9a96c Fixed: Generating absolute episode file paths in webhook events
Closes #7149
2024-08-30 23:24:08 -04:00
Weblate
66e4b7c819 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dream <seth.gecko.rr@gmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: MattiaPell <mattiapellegrini16@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: 极染 <poledye@icloud.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-08-30 20:21:48 -07:00
Bogdan
98c4cbdd13 Don't persist value for SslCertHash when checking for existence 2024-08-26 21:42:10 -07:00
Treycos
25d9f09a43 Convert SpinnerIcon to TypeScript 2024-08-27 00:41:58 -04:00
Treycos
7ea1301221 Convert TableRowCell to Typescript 2024-08-27 00:41:30 -04:00
Treycos
f033799d7a Convert IconButton to Typescript 2024-08-27 00:41:10 -04:00
Mark McDowall
cfa2f4d4c6 Fixed: Queue header 2024-08-27 00:40:43 -04:00
Sonarr
882b54be61 Automated API Docs update
ignore-downstream
2024-08-26 21:40:30 -07:00
Bogdan
041fdd3929 Convert Episode and Season search to TypeScript
Co-authored-by: Mark McDowall <markus.mcd5@gmail.com>
2024-08-26 21:40:22 -07:00
Sonarr
4548dcdf97 Automated API Docs update
ignore-downstream
2024-08-25 17:27:45 -07:00
Bogdan
4e14ce022c New: Bulk manage custom formats 2024-08-25 17:27:30 -07:00
Bogdan
a9b93dd9c6 Fixed: Paths for renamed episode files in Custom Script and Webhook 2024-08-25 17:24:52 -07:00
Bogdan
50d7e8fed4 Fixed: Hide reboot and shutdown UI buttons on docker 2024-08-25 17:24:40 -07:00
Bogdan
402db9128c New: Bypass IP addresses ranges in proxies 2024-08-25 17:24:30 -07:00
bakerboy448
846333ddf0 Fixed: Trim spaces and empty values in Proxy Bypass List 2024-08-25 20:24:16 -04:00
Bogdan
dde28cbd7e Fix disabled style for monitor toggle button 2024-08-25 17:23:33 -07:00
Bogdan
8ceb306bf1 Fixed: Ensure Root Folder exists when Adding Series 2024-08-25 20:23:24 -04:00
Treycos
8af4246ff9 Updated code action fixall value for VSCode 2024-08-25 20:22:42 -04:00
Treycos
a2e06e9e65 Link polymorphic static typing 2024-08-25 20:21:50 -04:00
Treycos
ae7b187e41 Convert Icon to Typescript 2024-08-25 20:21:06 -04:00
Treycos
63b4998c8e Convert Button to TypeScript 2024-08-25 20:20:52 -04:00
Mark McDowall
45665886d6 Bump version to 4.0.9 2024-08-25 16:52:30 -07:00
Weblate
860424ac22 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Jason54 <jason54700.jg@gmail.com>
Co-authored-by: Kerk en IT <info@kerkenit.nl>
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/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-08-25 16:52:16 -07:00
Mark McDowall
14005d8d10 Fixed: Limit redirects after login to local paths 2024-08-20 16:09:53 -07:00
Mark McDowall
da7d17f5e8 Fixed: PWA Manifest images
Closes #7125
2024-08-20 16:09:46 -07:00
Sonarr
ea331feb88 Automated API Docs update
ignore-downstream
2024-08-18 19:03:51 -07:00
Treycos
7dca9060ca Convert SeriesTitleLink to TypeScript 2024-08-18 19:01:32 -07:00
kephasdev
8af12cc4e7 Fixed: Calculating Custom Formats with languages in queue 2024-08-18 19:00:55 -07:00
Bogdan
aa488019cf Bump babel packages 2024-08-18 19:00:01 -07:00
Bogdan
47a05ecb36 Use autoprefixer in UI build 2024-08-18 19:00:01 -07:00
martylukyy
35baebaf72 New: Configure log file size limit in UI 2024-08-18 18:59:43 -07:00
Mark McDowall
aedcd046fc Fixed: PWA Manifest with URL base
Closes #7107
2024-08-18 18:58:29 -07:00
Bogdan
f45713bff8 Remove provider status on provider deletion 2024-08-18 18:58:10 -07:00
Mark McDowall
911a3d4c1e New: Parse spanish multi-episode releases 2024-08-18 18:57:25 -07:00
Mark McDowall
e16ace54a8 New: Optionally include Custom Format Score for Discord On File Import notifications 2024-08-18 18:57:17 -07:00
Stevie Robinson
84710a31bd New: Track Kometa metadata files
Closes #6851
2024-08-18 18:57:04 -07:00
Weblate
093a239e77 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Gabriel Markowski <gmarkowski62@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: YangForever88 <1026097197@qq.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/pl/
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/zh_CN/
Translation: Servarr/Sonarr
2024-08-18 18:56:50 -07:00
Bogdan
ee69351733 Fixed: Switch to series rating for Discord notifications 2024-08-18 18:55:40 -07:00
Bogdan
e92a67ad78 New: Show indicator on poster for deleted series 2024-08-18 18:55:26 -07:00
Treycos
3eca63a67c Convert Label to TypeScript 2024-08-18 18:54:30 -07:00
Treycos
8484a8beba Convert First Run to TypeScript 2024-08-18 18:52:04 -07:00
Sonarr
cd3a1c18ab Automated API Docs update
ignore-downstream
2024-08-14 20:24:03 -07:00
Bogdan
dc7a16a03a Sort quality profiles by name in custom filters 2024-08-14 20:23:44 -07:00
Bogdan
84338f4c50 Fixed: Stale formats score after changing quality profile for series 2024-08-14 20:23:31 -07:00
Mark McDowall
12ac123d5a Fixed: Prefer episode runtime when determining whether a file is a sample
Closes #7086
2024-08-14 20:22:50 -07:00
Mark McDowall
ef829c6ace New: Parse DarQ release group
Closes #7083
2024-08-14 23:22:37 -04:00
Bogdan
592b6f7f7c Fixed: Persist selected custom filter for interactive searches 2024-08-14 20:22:22 -07:00
Bogdan
be5b449de4 Fixed: Don't display multiple languages if no languages were parsed 2024-08-14 23:22:05 -04:00
Bogdan
9b144e9ade New: Increase max size limit for quality definitions
Closes #7084
2024-08-14 23:20:58 -04:00
Bogdan
9af2f137f4 Skip duplicate import list exclusions 2024-08-14 23:20:25 -04:00
Sonarr
d4bd7865f6 Automated API Docs update
ignore-downstream
2024-08-14 20:19:39 -07:00
Mark McDowall
cf921480ec New: Support for releases with absolute episode number and air date 2024-08-14 20:19:31 -07:00
Bogdan
639b53887d New: Bulk import list exclusions removal 2024-08-14 23:19:12 -04:00
Bogdan
3b29096e40 Fix wiki link for update healthcheck 2024-08-14 20:18:48 -07:00
Bogdan
2d237ae6b7 Cleanup old prop-types for TS 2024-08-14 20:18:48 -07:00
Bogdan
d713b83a36 Fixed: Sending Manual Interaction Required notifications for unknown series
For Discord/Webhooks/CustomScript
2024-08-14 20:18:39 -07:00
Bogdan
2f04b037a1 Fixed nlog deprecated calls 2024-08-11 09:08:38 -07:00
Bogdan
7b87de2e93 Clear pending changes for edit import list exclusions on modal close 2024-08-11 11:53:17 -04:00
Bogdan
eb2fd13509 Fixed: Overwriting query params for remove item handler (#7075) 2024-08-11 11:51:11 -04:00
Bogdan
ffdb08cfe6 Fixed: Dedupe titles to avoid similar search requests 2024-08-11 08:49:22 -07:00
Mark McDowall
37c4647f24 Fix typos and improve log messages 2024-08-11 08:48:33 -07:00
Mark McDowall
f7a58aab33 Align queue action buttons on right 2024-08-11 08:48:33 -07:00
Mark McDowall
4b186e894e Fixed: Marking queued item as failed not blocking the correct Torrent Info Hash 2024-08-11 11:48:22 -04:00
kephasdev
35a2bc9403 Fix: Use indexer's Multi Languages setting for pushed releases
Closes #7059
2024-08-11 11:47:59 -04:00
Bogdan
cc03ce04f1 Fixed: Formatting empty size on disk values 2024-08-11 08:46:56 -07:00
Bogdan
363f8fc347 New: Match search releases using IMDb ID if available 2024-08-11 11:46:46 -04:00
RaZaSB
0877a6718d New: Remove all single quote characters from searches 2024-08-11 11:46:02 -04:00
Bogdan
8b253c36ea Validation for bulk series editor 2024-08-11 11:45:15 -04:00
Bogdan
e6f82270a9 Parse TVDB ID for releases from HDBits
ignore-downstream
2024-08-11 11:45:00 -04:00
Mark McDowall
813965e6a2 New: Configurable log file size limit 2024-08-11 08:44:35 -07:00
Mark McDowall
0d914f4c53 New: Add Compact Log Event Format option for console logging
Closes #7045
2024-08-11 08:44:35 -07:00
Mark McDowall
ae7f73208a Upgrade nlog to 5.3.2 2024-08-11 08:44:35 -07:00
Weblate
4c86d673ea Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <arnaudthommeray+github@ik.me>
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/
Translation: Servarr/Sonarr
2024-08-11 08:44:27 -07:00
Weblate
b1527f9abb Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: iMohmmedSA <i.mohmmed.i+1@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-07-31 22:26:09 -07:00
Bogdan
291d792810 Fixed: Moving files on import for usenet clients
Closes #7043
2024-08-01 01:17:10 -04:00
Mark McDowall
9b528eb829 New: Default file log level changed to debug 2024-08-01 01:16:24 -04:00
Mark McDowall
4c0b896174 Improve messaging for for Send Notifications setting in Emby / Jellyfin
Closes #7042
2024-07-31 22:16:01 -07:00
Bogdan
4ff83f9efc Fixed: Persist Indexer Flags for automatic imports
Revert "Fixed: Persist Indexer Flags when manual importing from queue"

This reverts commit 217611d716.
2024-08-01 01:15:36 -04:00
Bogdan
217611d716 Fixed: Persist Indexer Flags when manual importing from queue 2024-07-31 00:28:01 -04:00
Mark McDowall
1299a97579 Update React Lint rules for TSX 2024-07-30 21:27:33 -07:00
Mark McDowall
4c0de55672 Fixed: Setting page size in Queue, History and Blocklist
Closes #7035
2024-07-30 21:27:33 -07:00
Bogdan
78a0def46a Fixed: Moving files for torrents when Remove Completed is disabled 2024-07-31 00:27:19 -04:00
Mark McDowall
11a9dcb389 New: Return downloading magnets from Transmission
Closes #7029
2024-07-31 00:26:24 -04:00
Mark McDowall
4eab168267 New: Add metadata links to telegram messages
Closes #5342
---------

Co-authored-by: Ivar Stangeby <istangeby@gmail.com>
2024-07-31 00:25:48 -04:00
Bogdan
c9b5a1258a New: Title filter for Series Index 2024-07-30 21:25:10 -07:00
Mark McDowall
9127a91dfc Fixed: Allow leading/trailing spaces on non-Windows
Closes #6971
2024-07-30 21:25:00 -07:00
Weblate
cc85a28ff7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Wolfy The Broccoly <theproviderofsolace@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/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-07-30 21:24:50 -07:00
Mark McDowall
72db8099e0 Convert System to TypeScript 2024-07-28 17:47:08 -07:00
Weblate
ebc5cdb335 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
2024-07-28 17:27:52 -07:00
Mark McDowall
d6d90a64a3 Convert App to TypeScript 2024-07-28 17:27:13 -07:00
Mark McDowall
d46f4b2154 Convert Utilities to TypeScript 2024-07-28 17:27:13 -07:00
Mark McDowall
76650af9fd Convert Queue to TypeScript 2024-07-28 16:59:48 -07:00
Mark McDowall
824ed0a369 Convert History to TypeScript 2024-07-28 16:59:48 -07:00
Mark McDowall
ee80564dd4 Convert Blocklist to TypeScript 2024-07-28 16:59:48 -07:00
Mark McDowall
3824eff5eb New: Parse Chinese Anime that separates titles with vertical bar
Closes #7014
2024-07-28 16:59:38 -07:00
Bogdan
15e3c3efb1 Include available version in update health check 2024-07-28 16:59:32 -07:00
Stevie Robinson
f2f4a98eed Fixed: Interactive Import dropdown width on mobile
Closes #7015
2024-07-28 16:59:21 -07:00
Mark McDowall
bc7799139e Don't hash files in development builds 2024-07-28 16:59:21 -07:00
Bogdan
33b62a2def New: Add TVMaze and TMDB IDs to Kodi .nfo (#7011)
Closes #6895
ignore-downstream
2024-07-28 19:59:10 -04:00
Mark McDowall
5ac6c0e651 Fix height of tags in tag inputs 2024-07-28 16:58:44 -07:00
Bogdan
60cba74c39 Bump ImageSharp to 3.1.5
https://github.com/advisories/GHSA-63p8-c4ww-9cg7
2024-07-28 16:58:39 -07:00
Bogdan
5c2c490cb2 Improve messaging for renamed episode files progress info 2024-07-28 16:58:32 -07:00
Mark McDowall
63fdf8ca8f Cache root folders and improve getting disk space for series path roots 2024-07-28 19:58:16 -04:00
Mark McDowall
e791f4b743 Fixed: Updating series path from different OS paths
Closes #6953
2024-07-28 19:57:54 -04:00
jbstark
6dd85a5af9 New: 'Seasons Monitored Status' Custom Filter to replace 'Has Unmonitored Season'
Closes #6896
2024-07-28 19:57:22 -04:00
Weblate
a80f5b794b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
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
2024-07-28 16:56:09 -07:00
Weblate
578f95546b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: marudosurdo <marudosurdo@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ja/
Translation: Servarr/Sonarr
2024-07-24 21:34:30 -07:00
ManiMatter
9a613afa35 Treat forcedMetaDL from qBit as queued instead of downloading 2024-07-25 00:33:08 -04:00
Mark McDowall
5ad3d2efcc Fixed: Don't treat SubFrench as French audio language
Closes #6995
2024-07-24 21:32:18 -07:00
Bogdan
1ad722acda Fixed: Improve performance in Select Series Modal 2024-07-25 00:32:09 -04:00
Bogdan
bde5f68142 Refresh series with recently aired episodes with TBA titles
Co-authored-by: Stevie Robinson <stevie.robinson@gmail.com>
2024-07-24 21:31:46 -07:00
Bogdan
fbda2d54c7 New: Display original language on series details and search results page
Closes #6984
2024-07-25 00:31:29 -04:00
Bogdan
2a26c6722a New: Ignore Litestream tables in Database 2024-07-25 00:30:56 -04:00
Bogdan
b7dfb8999d Improve tooltip for Next Airing on series Overview 2024-07-24 21:30:27 -07:00
Bogdan
1662521d40 Fixed: Display tag list when sort by tags on series Posters 2024-07-24 21:30:27 -07:00
Weblate
f8d75d174a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dream <seth.gecko.rr@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: 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/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-07-24 21:30:00 -07:00
Bogdan
80ca1a6ac2 Fixed: Editing Quality Profiles 2024-07-18 08:10:43 -07:00
Weblate
f59c0b16ca Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dream <seth.gecko.rr@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
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/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translation: Servarr/Sonarr
2024-07-16 21:42:50 -07:00
Mark McDowall
0e95ba2021 New: Allow major version updates to be installed 2024-07-16 21:39:49 -07:00
Mark McDowall
c023fc7008 New: Show update settings on all platforms 2024-07-16 21:39:49 -07:00
Mark McDowall
19466aa290 Fixed: Assume category path from qBittorent starting with '//' is a Windows UNC path
Radarr/Radarr#10162
2024-07-17 00:39:40 -04:00
Mark McDowall
4b5ef4907b Set default value for CustomColonReplacementFormat if not provided 2024-07-16 21:38:26 -07:00
Stevie Robinson
7b8d606a1b New: Wrap specifications in Custom Format and Auto Tagging modals 2024-07-17 00:38:15 -04:00
diamondpete
6a4824c029 Fixed: Remove apostrophe, backtick in contractions 2024-07-17 00:36:29 -04:00
Mark McDowall
1a1c8e6c08 New: Use natural sorting for lists of items in the UI
Closes #6955
2024-07-17 00:34:43 -04:00
Mark McDowall
e35b39b4b1 New: Add option to show tags on series Poster and Overview
Closes #6946
2024-07-16 21:34:25 -07:00
Mark McDowall
d3f14d5f5e Fixed: Parse Chinese anime formats with reverse title order 2024-07-16 21:34:11 -07:00
Mark McDowall
06936c4f22 Fixed: Parsing of Chinese anime with ordinal number in English title 2024-07-16 21:34:11 -07:00
Mark McDowall
0a28ff84e8 Fixed: Parsing of anime with 3 digit number in middle of title 2024-07-16 21:34:11 -07:00
Bogdan
703dee9383 New: Rating votes tooltip and series filter 2024-07-16 21:33:49 -07:00
Marc Carbonell
dca5239420 Remove extraneous indentation in RemoveFileExtension 2024-07-17 00:33:34 -04:00
Mark McDowall
1aaa9a14bc Bump version to 4.0.8 2024-07-15 21:36:26 -07:00
Weblate
c6c37a408a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dream <seth.gecko.rr@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translation: Servarr/Sonarr
2024-07-15 21:36:15 -07:00
Weblate
ae4a97b4ae Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dream <seth.gecko.rr@gmail.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/ru/
Translation: Servarr/Sonarr
2024-07-15 12:07:12 -07:00
Mark McDowall
3afae968eb Fixed: Import queue not processing after incomplete import 2024-07-15 12:03:53 -07:00
Mark McDowall
c01abbf3b5 Add 'On Import Complete' for Discord notifications 2024-07-15 12:03:31 -07:00
Mark McDowall
f5ccf98162 Rename 'On Upgrade' to 'On File Upgrade' 2024-07-15 12:03:31 -07:00
Mark McDowall
6afd3bd344 Bump version to 4.0.7 2024-07-14 16:44:07 -07:00
Weblate
acaf5cd353 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: MattiaPell <mattiapellegrini16@gmail.com>
Co-authored-by: Rauniik <raunerjakub@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/
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/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-07-14 16:43:56 -07:00
Sonarr
e97e5bfe8f Automated API Docs update
ignore-downstream
2024-07-09 22:22:48 -07:00
martylukyy
678872b879 Fixed: Parsing of some Web releases 2024-07-10 01:19:07 -04:00
Mark McDowall
10e9735c1c New: Update AutoTags on series update
Closes #6783
2024-07-10 01:02:23 -04:00
Mark McDowall
293a1bc618 New: Custom colon replacement option
Closes #6898
2024-07-10 01:02:04 -04:00
Bogdan
0c883f7886 Fixed: Removing pending release without blocklisting 2024-07-09 22:01:23 -07:00
Mark McDowall
46c7de379c New: Group updates for the same series for Kodi and Emby / Jellyfin 2024-07-09 22:00:57 -07:00
Mark McDowall
a83b521766 New: 'On Import Complete' notification when all episodes in a release are imported
Closes #363
2024-07-09 22:00:57 -07:00
Bogdan
1d06e40acb New: Queued episode count for seasons in series details 2024-07-09 21:59:41 -07:00
Weblate
bfcdc89f6a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Serhii Matrunchyk <serhii@digitalidea.studio>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/uk/
Translation: Servarr/Sonarr
2024-07-09 21:59:25 -07:00
Sonarr
67943edfbc Automated API Docs update
ignore-downstream
2024-07-05 16:35:21 -07:00
Mark McDowall
04f8595498 Custom Import List improvements
Fixed: Add placeholder title for Custom Import List title
New: Support 'title' property for Custom Import List
2024-07-05 16:35:08 -07:00
Bogdan
81ac73299a Fixed: Bulk series deletion for unmonitored series
Closes #6933
2024-07-05 19:34:56 -04:00
Mark McDowall
a779a5fad2 Fixed: Parsing of anime season releases with 3-digit number in title 2024-07-05 16:34:06 -07:00
Mark McDowall
bfe6a740fa Fixed: Parsing of anime releases using standard numbering
Closes #6925
2024-07-05 16:34:06 -07:00
Mark McDowall
c9ea40b874 New: Parse VFI as French
Closes #6927
2024-07-05 16:34:00 -07:00
Bogdan
4ee0ae1418 Fixed: History with unknown series 2024-07-05 16:33:50 -07:00
Bogdan
ac1da45ecd Fixed: Calculate Custom Formats after user specified options in Manual Import 2024-07-05 19:33:33 -04:00
Weblate
5c327d5be3 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: MattiaPell <mattiapellegrini16@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: quek76 <quek@libertysurf.fr>
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/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-07-05 16:31:56 -07:00
Mark McDowall
55c1ce2e3d Bump version to 4.0.6 2024-06-30 15:42:47 -07:00
Weblate
fd7f0ea973 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Kshitij Burman <kburman6@gmail.com>
Co-authored-by: MattiaPell <mattiapellegrini16@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: damienmillet <contact@damien-millet.dev>
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/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hi/
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/tr/
Translation: Servarr/Sonarr
2024-06-30 15:42:41 -07:00
Bogdan
d5dff8e8d6 Fixed: Trimming disabled logs database
Closes #6918
2024-06-30 13:49:41 -04:00
Bogdan
8099ba10af Fixed: Already imported downloads appearing in Queue briefly 2024-06-30 13:47:00 -04:00
Mark McDowall
143ccb1e2a Remove seriesTitle from EpisodeResource
Closes #6841
2024-06-28 06:22:10 -07:00
Mark McDowall
29480d9544 Fixed: Don't use cleaned up release title for release title 2024-06-28 06:22:04 -07:00
2081 changed files with 74383 additions and 70647 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,249 +0,0 @@
name: Build
on:
push:
branches:
- develop
- main
paths-ignore:
- 'src/Sonarr.Api.*/openapi.json'
pull_request:
branches:
- develop
paths-ignore:
- 'src/NzbDrone.Core/Localization/Core/**'
- 'src/Sonarr.Api.*/openapi.json'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
FRAMEWORK: net6.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 4
VERSION: 4.0.5
jobs:
backend:
runs-on: windows-latest
outputs:
framework: ${{ steps.variables.outputs.framework }}
major_version: ${{ steps.variables.outputs.major_version }}
version: ${{ steps.variables.outputs.version }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
- name: Setup Environment Variables
id: variables
shell: bash
run: |
# Add 800 to the build number because GitHub won't let us pick an arbitrary starting point
SONARR_VERSION="${{ env.VERSION }}.$((${{ github.run_number }}+800))"
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
echo "SONARR_VERSION=$SONARR_VERSION" >> "$GITHUB_ENV"
echo "BRANCH=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_ENV"
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT"
- name: Enable Extra Platforms In SDK
shell: bash
run: ./build.sh --enable-extra-platforms-in-sdk
- name: Build Backend
shell: bash
run: ./build.sh --backend --enable-extra-platforms --packages
# Test Artifacts
- name: Publish win-x64 Test Artifact
uses: ./.github/actions/publish-test-artifact
with:
framework: ${{ env.FRAMEWORK }}
runtime: win-x64
- name: Publish linux-x64 Test Artifact
uses: ./.github/actions/publish-test-artifact
with:
framework: ${{ env.FRAMEWORK }}
runtime: linux-x64
- name: Publish osx-arm64 Test Artifact
uses: ./.github/actions/publish-test-artifact
with:
framework: ${{ env.FRAMEWORK }}
runtime: osx-arm64
# Build Artifacts (grouped by OS)
- name: Publish FreeBSD Artifact
uses: actions/upload-artifact@v4
with:
name: build_freebsd
path: _artifacts/freebsd-*/**/*
- name: Publish Linux Artifact
uses: actions/upload-artifact@v4
with:
name: build_linux
path: _artifacts/linux-*/**/*
- name: Publish macOS Artifact
uses: actions/upload-artifact@v4
with:
name: build_macos
path: _artifacts/osx-*/**/*
- name: Publish Windows Artifact
uses: actions/upload-artifact@v4
with:
name: build_windows
path: _artifacts/win-*/**/*
frontend:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4
- name: Volta
uses: volta-cli/action@v4
- name: Yarn Install
run: yarn install
- name: Lint
run: yarn lint
- name: Stylelint
run: yarn stylelint -f github
- name: Build
run: yarn build --env production
- name: Publish UI Artifact
uses: actions/upload-artifact@v4
with:
name: build_ui
path: _output/UI/**/*
unit_test:
needs: backend
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- os: ubuntu-latest
artifact: tests-linux-x64
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
- os: macos-latest
artifact: tests-osx-arm64
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
- os: windows-latest
artifact: tests-win-x64
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: Test
uses: ./.github/actions/test
with:
os: ${{ matrix.os }}
artifact: ${{ matrix.artifact }}
pattern: Sonarr.*.Test.dll
filter: ${{ matrix.filter }}
unit_test_postgres:
needs: backend
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4
- name: Test
uses: ./.github/actions/test
with:
os: ubuntu-latest
artifact: tests-linux-x64
pattern: Sonarr.*.Test.dll
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
use_postgres: true
integration_test:
needs: backend
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- os: ubuntu-latest
artifact: tests-linux-x64
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
binary_artifact: build_linux
binary_path: linux-x64/${{ needs.backend.outputs.framework }}/Sonarr
- os: macos-latest
artifact: tests-osx-arm64
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
binary_artifact: build_macos
binary_path: osx-arm64/${{ needs.backend.outputs.framework }}/Sonarr
- os: windows-latest
artifact: tests-win-x64
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory=IntegrationTest
binary_artifact: build_windows
binary_path: win-x64/${{ needs.backend.outputs.framework }}/Sonarr
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: Test
uses: ./.github/actions/test
with:
os: ${{ matrix.os }}
artifact: ${{ matrix.artifact }}
pattern: Sonarr.*.Test.dll
filter: ${{ matrix.filter }}
integration_tests: true
binary_artifact: ${{ matrix.binary_artifact }}
binary_path: ${{ matrix.binary_path }}
deploy:
if: ${{ github.ref_name == 'develop' || github.ref_name == 'main' }}
needs: [backend, frontend, unit_test, unit_test_postgres, integration_test]
secrets: inherit
uses: ./.github/workflows/deploy.yml
with:
framework: ${{ needs.backend.outputs.framework }}
branch: ${{ github.ref_name }}
major_version: ${{ needs.backend.outputs.major_version }}
version: ${{ needs.backend.outputs.version }}
notify:
name: Discord Notification
needs: [backend, frontend, unit_test, unit_test_postgres, integration_test, deploy]
if: ${{ !cancelled() && (github.ref_name == 'develop' || github.ref_name == 'main') }}
env:
STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }}
runs-on: ubuntu-latest
steps:
- name: Notify
uses: tsickert/discord-webhook@v6.0.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
username: 'GitHub Actions'
avatar-url: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'
embed-title: "${{ github.workflow }}: ${{ env.STATUS == 'success' && 'Success' || 'Failure' }}"
embed-url: 'https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}'
embed-description: |
**Branch** ${{ github.ref }}
**Build** ${{ needs.backend.outputs.version }}
embed-color: ${{ env.STATUS == 'success' && '3066993' || '15158332' }}

254
.github/workflows/build_v5.yml vendored Normal file
View File

@@ -0,0 +1,254 @@
name: Build
on:
push:
branches:
- v5-develop
- v5-main
paths-ignore:
- "src/Sonarr.Api.*/openapi.json"
pull_request:
branches:
- v5-develop
paths-ignore:
- "src/NzbDrone.Core/Localization/Core/**"
- "src/Sonarr.Api.*/openapi.json"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
FRAMEWORK: net8.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 5
VERSION: 5.0.0
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
framework: ${{ steps.variables.outputs.framework }}
major_version: ${{ steps.variables.outputs.major_version }}
version: ${{ steps.variables.outputs.version }}
branch: ${{ steps.variables.outputs.branch }}
steps:
- name: Setup Environment Variables
id: variables
shell: bash
run: |
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
echo "version=${{ env.VERSION }}.$((${{ github.run_number }}))" >> "$GITHUB_OUTPUT"
echo "branch=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_OUTPUT"
backend:
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- runtime: freebsd-x64
package_tests: false
os: ubuntu-latest
- runtime: linux-arm
package_tests: false
os: ubuntu-latest
- runtime: linux-arm64
package_tests: false
os: ubuntu-latest
- runtime: linux-musl-arm64
package_tests: false
os: ubuntu-latest
- runtime: linux-musl-x64
package_tests: false
os: ubuntu-latest
- runtime: linux-x64
package_tests: true
os: ubuntu-latest
- runtime: osx-arm64
package_tests: true
os: ubuntu-latest
- runtime: osx-x64
package_tests: false
os: ubuntu-latest
- runtime: win-x64
package_tests: true
os: ubuntu-latest
- runtime: win-x86
package_tests: false
os: ubuntu-latest
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: Build
uses: ./.github/actions/build
with:
branch: ${{ needs.prepare.outputs.branch }}
version: ${{ needs.prepare.outputs.version }}
framework: ${{ needs.prepare.outputs.framework }}
runtime: ${{ matrix.runtime }}
package_tests: ${{ matrix.package_tests }}
frontend:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4
- name: Volta
uses: volta-cli/action@v4
- name: Yarn Install
run: yarn install
- name: Lint
run: yarn lint
- name: Stylelint
run: yarn stylelint -f github
- name: Build
run: yarn build --env production
- name: Publish UI Artifact
uses: actions/upload-artifact@v4
with:
name: build_ui
path: _output/UI/**/*
unit_test:
needs: backend
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- os: ubuntu-latest
artifact: tests-linux-x64
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
- os: macos-latest
artifact: tests-osx-arm64
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
- os: windows-latest
artifact: tests-win-x64
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: Test
uses: ./.github/actions/test
with:
os: ${{ matrix.os }}
artifact: ${{ matrix.artifact }}
pattern: Sonarr.*.Test.dll
filter: ${{ matrix.filter }}
unit_test_postgres:
needs: backend
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4
- name: Test
uses: ./.github/actions/test
with:
os: ubuntu-latest
artifact: tests-linux-x64
pattern: Sonarr.*.Test.dll
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
use_postgres: true
integration_test:
needs: [prepare, backend]
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- os: ubuntu-latest
artifact: tests-linux-x64
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
binary_artifact: build-linux-x64
binary_path: linux-x64/${{ needs.prepare.outputs.framework }}/Sonarr
- os: macos-latest
artifact: tests-osx-arm64
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
binary_artifact: build-osx-arm64
binary_path: osx-arm64/${{ needs.prepare.outputs.framework }}/Sonarr
- os: windows-latest
artifact: tests-win-x64
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory=IntegrationTest
binary_artifact: build-win-x64
binary_path: win-x64/${{ needs.prepare.outputs.framework }}/Sonarr
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
- name: Test
uses: ./.github/actions/test
with:
os: ${{ matrix.os }}
artifact: ${{ matrix.artifact }}
pattern: Sonarr.*.Test.dll
filter: ${{ matrix.filter }}
integration_tests: true
binary_artifact: ${{ matrix.binary_artifact }}
binary_path: ${{ matrix.binary_path }}
deploy:
if: ${{ github.ref_name == 'v5-develop-final' || github.ref_name == 'v5-main-final' }}
needs:
[
prepare,
backend,
frontend,
unit_test,
unit_test_postgres,
integration_test,
]
secrets: inherit
uses: ./.github/workflows/deploy.yml
with:
framework: ${{ needs.prepare.outputs.framework }}
branch: ${{ github.ref_name }}
major_version: ${{ needs.prepare.outputs.major_version }}
version: ${{ needs.prepare.outputs.version }}
notify:
name: Discord Notification
needs:
[
prepare,
backend,
frontend,
unit_test,
unit_test_postgres,
integration_test,
deploy,
]
if: ${{ !cancelled() && (github.ref_name == 'v5-develop-final' || github.ref_name == 'v5-main-final') }}
env:
STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }}
runs-on: ubuntu-latest
steps:
- name: Notify
uses: tsickert/discord-webhook@v6.0.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
username: "GitHub Actions"
avatar-url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
embed-title: "${{ github.workflow }}: ${{ env.STATUS == 'success' && 'Success' || 'Failure' }}"
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
embed-description: |
**Branch** ${{ github.ref }}
**Build** ${{ needs.prepare.outputs.version }}
embed-color: ${{ env.STATUS == 'success' && '3066993' || '15158332' }}

View File

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

View File

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

29
.github/workflows/support-requests.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: 'Support Requests'
on:
issues:
types: [labeled, unlabeled, reopened]
permissions:
issues: write
jobs:
action:
runs-on: ubuntu-latest
if: github.repository == 'Sonarr/Sonarr'
steps:
- uses: dessant/support-requests@v4
with:
github-token: ${{ github.token }}
support-label: 'support'
issue-comment: >
:wave: @{issue-author}, we use the issue tracker exclusively
for bug reports and feature requests. However, this issue appears
to be a support request. Please use one of the support channels:
[forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/),
[discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr)
for support/questions.
close-issue: true
issue-close-reason: 'not planned'
lock-issue: false
issue-lock-reason: 'off-topic'

3
.gitignore vendored
View File

@@ -162,3 +162,6 @@ src/.idea/
# API doc generation
.config/
# Ignore Jetbrains IntelliJ Workspace Directories
.idea/

2
.vscode/launch.json vendored
View File

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

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib"
}

View File

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

View File

@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-1.3318" y1="43.7371" x2="67.0419" y2="26.0967">
<stop offset="0.1237" style="stop-color:#7866FF"/>
<stop offset="0.5376" style="stop-color:#FE2EB6"/>
<stop offset="0.8548" style="stop-color:#FD0486"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="67.3,16 43.7,0 0,31.1 11.1,70 58.9,60.3 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="45.9148" y1="38.9098" x2="67.6577" y2="9.0989">
<stop offset="0.1237" style="stop-color:#FF0080"/>
<stop offset="0.2587" style="stop-color:#FE0385"/>
<stop offset="0.4109" style="stop-color:#FA0C92"/>
<stop offset="0.5713" style="stop-color:#F41BA9"/>
<stop offset="0.7363" style="stop-color:#EB2FC8"/>
<stop offset="0.8656" style="stop-color:#E343E6"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="67.3,16 43.7,0 38,15.7 38,47.8 70,47.8 "/>
</g>
<g>
<rect x="13.4" y="13.4" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.4" y="48.5" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<g>
<path style="fill:#FFFFFF;" d="M17.4,19.1h6.9c5.6,0,9.5,3.8,9.5,8.9V28c0,5-3.9,8.9-9.5,8.9h-6.9V19.1z M21.4,22.7v10.7h3
c3.2,0,5.4-2.2,5.4-5.3V28c0-3.2-2.2-5.4-5.4-5.4H21.4z"/>
<polygon style="fill:#FFFFFF;" points="40.3,22.7 34.9,22.7 34.9,19.1 49.6,19.1 49.6,22.7 44.2,22.7 44.2,37 40.3,37 "/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="120.1px" height="130.2px" viewBox="0 0 120.1 130.2" style="enable-background:new 0 0 120.1 130.2;" xml:space="preserve"
>
<g>
<linearGradient id="XMLID_2_" gradientUnits="userSpaceOnUse" x1="31.8412" y1="120.5578" x2="110.2402" y2="73.24">
<stop offset="0" style="stop-color:#FCEE39"/>
<stop offset="1" style="stop-color:#F37B3D"/>
</linearGradient>
<path id="XMLID_3041_" style="fill:url(#XMLID_2_);" d="M118.6,71.8c0.9-0.8,1.4-1.9,1.5-3.2c0.1-2.6-1.8-4.7-4.4-4.9
c-1.2-0.1-2.4,0.4-3.3,1.1l0,0l-83.8,45.9c-1.9,0.8-3.6,2.2-4.7,4.1c-2.9,4.8-1.3,11,3.6,13.9c3.4,2,7.5,1.8,10.7-0.2l0,0l0,0
c0.2-0.2,0.5-0.3,0.7-0.5l78-54.8C117.3,72.9,118.4,72.1,118.6,71.8L118.6,71.8L118.6,71.8z"/>
<linearGradient id="XMLID_3_" gradientUnits="userSpaceOnUse" x1="48.3607" y1="6.9083" x2="119.9179" y2="69.5546">
<stop offset="0" style="stop-color:#EF5A6B"/>
<stop offset="0.57" style="stop-color:#F26F4E"/>
<stop offset="1" style="stop-color:#F37B3D"/>
</linearGradient>
<path id="XMLID_3049_" style="fill:url(#XMLID_3_);" d="M118.8,65.1L118.8,65.1L55,2.5C53.6,1,51.6,0,49.3,0
c-4.3,0-7.7,3.5-7.7,7.7v0c0,2.1,0.8,3.9,2.1,5.3l0,0l0,0c0.4,0.4,0.8,0.7,1.2,1l67.4,57.7l0,0c0.8,0.7,1.8,1.2,3,1.3
c2.6,0.1,4.7-1.8,4.9-4.4C120.2,67.3,119.7,66,118.8,65.1z"/>
<linearGradient id="XMLID_4_" gradientUnits="userSpaceOnUse" x1="52.9467" y1="63.6407" x2="10.5379" y2="37.1562">
<stop offset="0" style="stop-color:#7C59A4"/>
<stop offset="0.3852" style="stop-color:#AF4C92"/>
<stop offset="0.7654" style="stop-color:#DC4183"/>
<stop offset="0.957" style="stop-color:#ED3D7D"/>
</linearGradient>
<path id="XMLID_3042_" style="fill:url(#XMLID_4_);" d="M57.1,59.5C57,59.5,17.7,28.5,16.9,28l0,0l0,0c-0.6-0.3-1.2-0.6-1.8-0.9
c-5.8-2.2-12.2,0.8-14.4,6.6c-1.9,5.1,0.2,10.7,4.6,13.4l0,0l0,0C6,47.5,6.6,47.8,7.3,48c0.4,0.2,45.4,18.8,45.4,18.8l0,0
c1.8,0.8,3.9,0.3,5.1-1.2C59.3,63.7,59,61,57.1,59.5z"/>
<linearGradient id="XMLID_5_" gradientUnits="userSpaceOnUse" x1="52.1736" y1="3.7019" x2="10.7706" y2="37.8971">
<stop offset="0" style="stop-color:#EF5A6B"/>
<stop offset="0.364" style="stop-color:#EE4E72"/>
<stop offset="1" style="stop-color:#ED3D7D"/>
</linearGradient>
<path id="XMLID_3057_" style="fill:url(#XMLID_5_);" d="M49.3,0c-1.7,0-3.3,0.6-4.6,1.5L4.9,28.3c-0.1,0.1-0.2,0.1-0.2,0.2l-0.1,0
l0,0c-1.7,1.2-3.1,3-3.9,5.1C-1.5,39.4,1.5,45.9,7.3,48c3.6,1.4,7.5,0.7,10.4-1.4l0,0l0,0c0.7-0.5,1.3-1,1.8-1.6l34.6-31.2l0,0
c1.8-1.4,3-3.6,3-6.1v0C57.1,3.5,53.6,0,49.3,0z"/>
<g id="XMLID_3008_">
<rect id="XMLID_3033_" x="34.6" y="37.4" style="fill:#000000;" width="51" height="51"/>
<rect id="XMLID_3032_" x="39" y="78.8" style="fill:#FFFFFF;" width="19.1" height="3.2"/>
<g id="XMLID_3009_">
<path id="XMLID_3030_" style="fill:#FFFFFF;" d="M38.8,50.8l1.5-1.4c0.4,0.5,0.8,0.8,1.3,0.8c0.6,0,0.9-0.4,0.9-1.2l0-5.3l2.3,0
l0,5.3c0,1-0.3,1.8-0.8,2.3c-0.5,0.5-1.3,0.8-2.3,0.8C40.2,52.2,39.4,51.6,38.8,50.8z"/>
<path id="XMLID_3028_" style="fill:#FFFFFF;" d="M45.3,43.8l6.7,0v1.9l-4.4,0V47l4,0l0,1.8l-4,0l0,1.3l4.5,0l0,2l-6.7,0
L45.3,43.8z"/>
<path id="XMLID_3026_" style="fill:#FFFFFF;" d="M55,45.8l-2.5,0l0-2l7.3,0l0,2l-2.5,0l0,6.3l-2.3,0L55,45.8z"/>
<path id="XMLID_3022_" style="fill:#FFFFFF;" d="M39,54l4.3,0c1,0,1.8,0.3,2.3,0.7c0.3,0.3,0.5,0.8,0.5,1.4v0
c0,1-0.5,1.5-1.3,1.9c1,0.3,1.6,0.9,1.6,2v0c0,1.4-1.2,2.3-3.1,2.3l-4.3,0L39,54z M43.8,56.6c0-0.5-0.4-0.7-1-0.7l-1.5,0l0,1.5
l1.4,0C43.4,57.3,43.8,57.1,43.8,56.6L43.8,56.6z M43,59l-1.8,0l0,1.5H43c0.7,0,1.1-0.3,1.1-0.8v0C44.1,59.2,43.7,59,43,59z"/>
<path id="XMLID_3019_" style="fill:#FFFFFF;" d="M46.8,54l3.9,0c1.3,0,2.1,0.3,2.7,0.9c0.5,0.5,0.7,1.1,0.7,1.9v0
c0,1.3-0.7,2.1-1.7,2.6l2,2.9l-2.6,0l-1.7-2.5h-1l0,2.5l-2.3,0L46.8,54z M50.6,58c0.8,0,1.2-0.4,1.2-1v0c0-0.7-0.5-1-1.2-1
l-1.5,0v2H50.6z"/>
<path id="XMLID_3016_" style="fill:#FFFFFF;" d="M56.8,54l2.2,0l3.5,8.4l-2.5,0l-0.6-1.5l-3.2,0l-0.6,1.5l-2.4,0L56.8,54z
M58.8,59l-0.9-2.3L57,59L58.8,59z"/>
<path id="XMLID_3014_" style="fill:#FFFFFF;" d="M62.8,54l2.3,0l0,8.3l-2.3,0L62.8,54z"/>
<path id="XMLID_3012_" style="fill:#FFFFFF;" d="M65.7,54l2.1,0l3.4,4.4l0-4.4l2.3,0l0,8.3l-2,0L68,57.8l0,4.6l-2.3,0L65.7,54z"
/>
<path id="XMLID_3010_" style="fill:#FFFFFF;" d="M73.7,61.1l1.3-1.5c0.8,0.7,1.7,1,2.7,1c0.6,0,1-0.2,1-0.6v0
c0-0.4-0.3-0.5-1.4-0.8c-1.8-0.4-3.1-0.9-3.1-2.6v0c0-1.5,1.2-2.7,3.2-2.7c1.4,0,2.5,0.4,3.4,1.1l-1.2,1.6
c-0.8-0.5-1.6-0.8-2.3-0.8c-0.6,0-0.8,0.2-0.8,0.5v0c0,0.4,0.3,0.5,1.4,0.8c1.9,0.4,3.1,1,3.1,2.6v0c0,1.7-1.3,2.7-3.4,2.7
C76.1,62.5,74.7,62,73.7,61.1z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="22.9451" y1="75.7869" x2="74.7868" y2="20.6415">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.4044" style="stop-color:#C41E57"/>
<stop offset="0.4677" style="stop-color:#C41E57"/>
<stop offset="0.6505" style="stop-color:#EB8523"/>
<stop offset="0.9516" style="stop-color:#FEBD11"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="49.8,15.2 36,36.7 58.4,70 70,23.1 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="17.7187" y1="73.2922" x2="69.5556" y2="18.1519">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.4044" style="stop-color:#C41E57"/>
<stop offset="0.4677" style="stop-color:#C41E57"/>
<stop offset="0.7043" style="stop-color:#EB8523"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="51.1,15.7 49,0 18.8,33.6 27.6,42.3 20.8,70 58.4,70 "/>
</g>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="1.8281" y1="53.4275" x2="48.8245" y2="9.2255">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.6613" style="stop-color:#C41E57"/>
</linearGradient>
<polygon style="fill:url(#SVGID_3_);" points="49,0 11.6,0 0,47.1 55.6,47.1 "/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="49.8935" y1="-11.5569" x2="48.8588" y2="24.0352">
<stop offset="0.5" style="stop-color:#C41E57"/>
<stop offset="0.6668" style="stop-color:#D13F48"/>
<stop offset="0.7952" style="stop-color:#D94F39"/>
<stop offset="0.8656" style="stop-color:#DD5433"/>
</linearGradient>
<polygon style="fill:url(#SVGID_4_);" points="55.3,47.1 51.1,15.7 49,0 41.7,23 "/>
</g>
<g>
<rect x="13.4" y="13.5" transform="matrix(-1 2.577289e-003 -2.577289e-003 -1 70.0288 70.081)" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.6" y="48.6" transform="matrix(1 -2.577289e-003 2.577289e-003 1 -0.1287 6.634109e-002)" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<path style="fill:#FFFFFF;" d="M17.4,19.1l8.2,0c2.3,0,4,0.6,5.2,1.8c1,1,1.5,2.4,1.5,4.1l0,0.1c0,1.5-0.3,2.6-1.1,3.5
c-0.7,0.9-1.6,1.6-2.8,2l4.4,6.4l-4.6,0l-3.7-5.5l-3.3,0l0,5.5l-3.9,0L17.4,19.1z M25.3,27.8c1,0,1.7-0.2,2.2-0.7
c0.5-0.5,0.8-1.1,0.8-1.8l0-0.1c0-0.9-0.3-1.5-0.8-1.9c-0.5-0.4-1.3-0.6-2.3-0.6l-3.9,0l0,5.1L25.3,27.8z"/>
<path style="fill:#FFFFFF;" d="M36,33.2l-1.9,0l0-3.3l2.5,0l0.6-3.8l-2.3,0l0-3.3l2.8,0l0.6-3.7l3.4,0l-0.6,3.7l3.7,0l0.6-3.7
l3.4,0l-0.6,3.7l1.9,0l0,3.3l-2.5,0L47,29.9l2.3,0l0,3.3l-2.8,0L45.8,37l-3.4,0l0.7-3.8l-3.7,0L38.7,37l-3.4,0L36,33.2z
M43.7,29.9l0.6-3.8l-3.7,0L40,29.9L43.7,29.9z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1.7738" y1="31.2729" x2="40.1662" y2="31.2729">
<stop offset="0" style="stop-color:#905CFB"/>
<stop offset="6.772543e-002" style="stop-color:#776CF9"/>
<stop offset="0.1729" style="stop-color:#5681F7"/>
<stop offset="0.2865" style="stop-color:#3B92F5"/>
<stop offset="0.4097" style="stop-color:#269FF4"/>
<stop offset="0.5474" style="stop-color:#17A9F3"/>
<stop offset="0.7111" style="stop-color:#0FAEF2"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_1_);" d="M39.7,47.9l-6.1-34c-0.4-2.4-1.2-4.8-2.7-7.1c-2-3.2-5.2-5.4-8.8-6.3
C7.9-2.9-2.6,11.3,3.6,23.9c0,0,0,0,0,0l14.8,31.7c0.4,1,1,2,1.7,2.9c1.2,1.6,2.8,2.8,4.7,3.4C34.4,64.9,42.1,56.4,39.7,47.9z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="5.3113" y1="9.6691" x2="69.2278" y2="43.8664">
<stop offset="0" style="stop-color:#905CFB"/>
<stop offset="6.772543e-002" style="stop-color:#776CF9"/>
<stop offset="0.1729" style="stop-color:#5681F7"/>
<stop offset="0.2865" style="stop-color:#3B92F5"/>
<stop offset="0.4097" style="stop-color:#269FF4"/>
<stop offset="0.5474" style="stop-color:#17A9F3"/>
<stop offset="0.7111" style="stop-color:#0FAEF2"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_2_);" d="M67.4,26.5c-1.4-2.2-3.4-3.9-5.7-4.9L25.5,1.7l0,0c-1-0.5-2.1-1-3.3-1.3
C6.7-3.2-4.4,13.8,5.5,27c1.5,2,3.6,3.6,6,4.5L48,47.9c0.8,0.5,1.6,0.8,2.5,1.1C64.5,53.4,75.1,38.6,67.4,26.5z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-19.2836" y1="70.8198" x2="55.9833" y2="33.1863">
<stop offset="0" style="stop-color:#3BEA62"/>
<stop offset="0.117" style="stop-color:#31DE80"/>
<stop offset="0.3025" style="stop-color:#24CEA8"/>
<stop offset="0.4844" style="stop-color:#1AC1C9"/>
<stop offset="0.6592" style="stop-color:#12B7DF"/>
<stop offset="0.8238" style="stop-color:#0EB2ED"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_3_);" d="M67.4,26.5c-1.8-2.8-4.6-4.8-7.9-5.6c-3.5-0.8-6.8-0.5-9.6,0.7L11.4,36.1
c0,0-0.2,0.1-0.6,0.4C0.9,40.4-4,53.3,4,64c1.8,2.4,4.3,4.2,7.1,5c5.3,1.6,10.1,1,14-1.1c0,0,0.1,0,0.1,0l37.6-20.1
c0,0,0,0,0.1-0.1C69.5,43.9,72.6,34.6,67.4,26.5z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="38.9439" y1="5.8503" x2="5.4232" y2="77.5093">
<stop offset="0" style="stop-color:#3BEA62"/>
<stop offset="9.397750e-002" style="stop-color:#2FDB87"/>
<stop offset="0.196" style="stop-color:#24CEA8"/>
<stop offset="0.3063" style="stop-color:#1BC3C3"/>
<stop offset="0.4259" style="stop-color:#14BAD8"/>
<stop offset="0.5596" style="stop-color:#10B5E7"/>
<stop offset="0.7185" style="stop-color:#0DB1EF"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_4_);" d="M50.3,12.8c1.2-2.7,1.1-6-0.9-9c-1.1-1.8-2.9-3-4.9-3.5c-4.5-1.1-8.3,1-10.1,4.2L3.5,42
c0,0,0,0,0,0.1C-0.9,47.9-1.6,56.5,4,64c1.8,2.4,4.3,4.2,7.1,5c10.5,3.3,19.3-2.5,22.1-10.8L50.3,12.8z"/>
</g>
<g>
<rect x="13.4" y="13.4" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.5" y="48.5" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<polygon style="fill:#FFFFFF;" points="22.9,22.7 17.5,22.7 17.5,19.1 32.3,19.1 32.3,22.7 26.8,22.7 26.8,37 22.9,37 "/>
<path style="fill:#FFFFFF;" d="M32.5,28.1L32.5,28.1c0-5.1,3.8-9.3,9.3-9.3c3.4,0,5.4,1.1,7.1,2.8l-2.5,2.9c-1.4-1.3-2.8-2-4.6-2
c-3,0-5.2,2.5-5.2,5.6V28c0,3.1,2.1,5.6,5.2,5.6c2,0,3.3-0.8,4.7-2.1l2.5,2.5c-1.8,2-3.9,3.2-7.3,3.2
C36.4,37.3,32.5,33.2,32.5,28.1"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,6 +1,6 @@
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
[![Translated](https://translate.servarr.com/widgets/servarr/-/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/)
[![Translated](https://translate.servarr.com/widget/servarr/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/)
[![Backers on Open Collective](https://opencollective.com/Sonarr/backers/badge.svg)](#backers)
[![Sponsors on Open Collective](https://opencollective.com/Sonarr/sponsors/badge.svg)](#sponsors)
[![Mega Sponsors on Open Collective](https://opencollective.com/Sonarr/megasponsors/badge.svg)](#mega-sponsors)
@@ -12,7 +12,7 @@ Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS fee
- [Download/Installation](https://sonarr.tv/#downloads-v3)
- [FAQ](https://wiki.servarr.com/sonarr/faq)
- [Wiki](https://wiki.servarr.com/Sonarr)
- [v4 Beta API Documentation](https://sonarr.tv/docs/api)
- [API Documentation](https://sonarr.tv/docs/api)
- [Donate](https://sonarr.tv/donate)
## Support
@@ -33,7 +33,7 @@ Note: GitHub Issues are for Bugs and Feature Requests Only
- Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc.
- Automatically detects new episodes
- Can scan your existing library and download any missing episodes
- Can watch for better quality of the episodes you already have and do an automatic upgrade. *eg. from DVD to Blu-Ray*
- Can watch for better quality of the episodes you already have and do an automatic upgrade. _eg. from DVD to Blu-Ray_
- Automatic failed download handling will try another release if one fails
- Manual search so you can pick any release or to see why a release was not downloaded automatically
- Fully configurable episode renaming
@@ -52,7 +52,7 @@ This project exists thanks to all the people who contribute. [Contribute](CONTRI
### Supporters
This project would not be possible without the support of our users and software providers.
This project would not be possible without the support of our users and software providers.
[**Become a sponsor or backer**](https://opencollective.com/sonarr) to help us out!
#### Mega Sponsors
@@ -69,13 +69,17 @@ This project would not be possible without the support of our users and software
#### JetBrains
Thank you to [<img src="/Logo/Jetbrains/jetbrains.svg" alt="JetBrains" width="32"> JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools
Thank you to [<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.png" alt="JetBrains" width="96">](http://www.jetbrains.com/) for providing us with free licenses to their great tools
* [<img src="/Logo/Jetbrains/teamcity.svg" alt="TeamCity" width="32"> TeamCity](http://www.jetbrains.com/teamcity/)
* [<img src="/Logo/Jetbrains/resharper.svg" alt="ReSharper" width="32"> ReSharper](http://www.jetbrains.com/resharper/)
* [<img src="/Logo/Jetbrains/dottrace.svg" alt="dotTrace" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/TeamCity.png" alt="TeamCity" width="64">](http://www.jetbrains.com/teamcity/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/ReSharper.png" alt="ReSharper" width="64">](http://www.jetbrains.com/resharper/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/dotTrace.png" alt="dotTrace" width="64">](http://www.jetbrains.com/dottrace/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/Rider.png" alt="Rider" width="64">](http://www.jetbrains.com/rider/)
### Licenses
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2023
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2024

455
build.sh
View File

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

View File

@@ -59,7 +59,7 @@ app_guid=$(echo "$app_guid" | tr -d ' ')
app_guid=${app_guid:-media}
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that that user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
# Create User / Group as needed
@@ -114,7 +114,7 @@ case "$ARCH" in
esac
echo ""
echo "Removing previous tarballs"
# -f to Force so we fail if it doesnt exist
# -f to Force so we fail if it doesn't exist
rm -f "${app^}".*.tar.gz
echo ""
echo "Downloading..."

View File

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

11
docs.sh
View File

@@ -1,15 +1,16 @@
#!/bin/bash
set -e
FRAMEWORK="net6.0"
FRAMEWORK="net8.0"
PLATFORM=$1
ARCHITECTURE="${2:-x64}"
if [ "$PLATFORM" = "Windows" ]; then
RUNTIME="win-x64"
RUNTIME="win-$ARCHITECTURE"
elif [ "$PLATFORM" = "Linux" ]; then
RUNTIME="linux-x64"
RUNTIME="linux-$ARCHITECTURE"
elif [ "$PLATFORM" = "Mac" ]; then
RUNTIME="osx-x64"
RUNTIME="osx-$ARCHITECTURE"
else
echo "Platform must be provided as first argument: Windows, Linux or Mac"
exit 1
@@ -39,7 +40,7 @@ dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p
dotnet new tool-manifest
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
dotnet tool run swagger tofile --output ./src/Sonarr.Api.V3/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v3 &
dotnet tool run swagger tofile --output ./src/Sonarr.Api.V5/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v5 &
sleep 45

View File

@@ -210,7 +210,6 @@ module.exports = {
'no-undef-init': 'off',
'no-undefined': 'off',
'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }],
'no-use-before-define': 'error',
// Node.js and CommonJS
@@ -359,11 +358,20 @@ module.exports = {
],
rules: Object.assign(typescriptEslintRecommended.rules, {
'no-shadow': 'off',
// These should be enabled after cleaning things up
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'after-used',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
}
],
'@typescript-eslint/explicit-function-return-type': 'off',
'react/prop-types': 'off',
'no-shadow': 'off',
'prettier/prettier': 'error',
'simple-import-sort/imports': [
'error',
@@ -376,7 +384,41 @@ module.exports = {
['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$']
]
}
]
],
// React Hooks
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// React
'react/function-component-definition': 'error',
'react/hook-use-state': 'error',
'react/jsx-boolean-value': ['error', 'always'],
'react/jsx-curly-brace-presence': [
'error',
{ props: 'never', children: 'never' }
],
'react/jsx-fragments': 'error',
'react/jsx-handler-names': [
'error',
{
eventHandlerPrefix: 'on',
eventHandlerPropPrefix: 'on'
}
],
'react/jsx-no-bind': ['error', { ignoreRefs: true }],
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }],
'react/jsx-pascal-case': ['error', { allowAllCaps: true }],
'react/jsx-sort-props': [
'error',
{
callbacksLast: true,
noSortAlphabetically: true,
reservedFirst: true
}
],
'react/prop-types': 'off',
'react/self-closing-comp': 'error'
})
},
{

View File

@@ -9,7 +9,7 @@
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll": true
"source.fixAll": "explicit"
},
"typescript.preferences.quoteStyle": "single",

View File

@@ -14,7 +14,6 @@ module.exports = (env) => {
const srcFolder = path.join(frontendFolder, 'src');
const isProduction = !!env.production;
const isProfiling = isProduction && !!env.profile;
const inlineWebWorkers = 'no-fallback';
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
@@ -26,6 +25,7 @@ module.exports = (env) => {
const config = {
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map',
target: 'web',
stats: {
children: false
@@ -51,8 +51,7 @@ module.exports = (env) => {
'node_modules'
],
alias: {
jquery: 'jquery/dist/jquery.min',
'react-middle-truncate': 'react-middle-truncate/lib/react-middle-truncate'
jquery: 'jquery/dist/jquery.min'
},
fallback: {
buffer: false,
@@ -67,7 +66,7 @@ module.exports = (env) => {
output: {
path: distFolder,
publicPath: '/',
filename: '[name]-[contenthash].js',
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
sourceMapFilename: '[file].map'
},
@@ -92,7 +91,7 @@ module.exports = (env) => {
new MiniCssExtractPlugin({
filename: 'Content/styles.css',
chunkFilename: 'Content/[id]-[chunkhash].css'
chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css'
}),
new HtmlWebpackPlugin({
@@ -134,6 +133,12 @@ module.exports = (env) => {
{
source: 'frontend/src/Content/robots.txt',
destination: path.join(distFolder, 'Content/robots.txt')
},
// manifest.json and browserconfig.xml
{
source: 'frontend/src/Content/*.(json|xml)',
destination: path.join(distFolder, 'Content')
}
]
}
@@ -154,16 +159,6 @@ module.exports = (env) => {
module: {
rules: [
{
test: /\.worker\.js$/,
use: {
loader: 'worker-loader',
options: {
filename: '[name].js',
inline: inlineWebWorkers
}
}
},
{
test: [/\.jsx?$/, /\.tsx?$/],
exclude: /(node_modules|JsLibraries)/,
@@ -181,7 +176,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: 3
corejs: '3.39'
}
]
]
@@ -202,7 +197,7 @@ module.exports = (env) => {
options: {
importLoaders: 1,
modules: {
localIdentName: '[name]/[local]/[hash:base64:5]'
localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]'
}
}
},

View File

@@ -16,6 +16,7 @@ const mixinsFiles = [
module.exports = {
plugins: [
'autoprefixer',
['postcss-mixins', {
mixinsFiles
}],

View File

@@ -1,284 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import 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 { align, icons, kinds } from 'Helpers/Props';
import getRemovedItems from 'Utilities/Object/getRemovedItems';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import BlocklistFilterModal from './BlocklistFilterModal';
import BlocklistRowConnector from './BlocklistRowConnector';
class Blocklist extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
isConfirmRemoveModalOpen: false,
isConfirmClearModalOpen: false,
items: props.items
};
}
componentDidUpdate(prevProps) {
const {
items
} = this.props;
if (hasDifferentItems(prevProps.items, items)) {
this.setState((state) => {
return {
...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)),
items
};
});
return;
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
};
onRemoveSelectedPress = () => {
this.setState({ isConfirmRemoveModalOpen: true });
};
onRemoveSelectedConfirmed = () => {
this.props.onRemoveSelected(this.getSelectedIds());
this.setState({ isConfirmRemoveModalOpen: false });
};
onConfirmRemoveModalClose = () => {
this.setState({ isConfirmRemoveModalOpen: false });
};
onClearBlocklistPress = () => {
this.setState({ isConfirmClearModalOpen: true });
};
onClearBlocklistConfirmed = () => {
this.props.onClearBlocklistPress();
this.setState({ isConfirmClearModalOpen: false });
};
onConfirmClearModalClose = () => {
this.setState({ isConfirmClearModalOpen: false });
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
customFilters,
totalRecords,
isRemoving,
isClearingBlocklistExecuting,
onFilterSelect,
...otherProps
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
isConfirmRemoveModalOpen,
isConfirmClearModalOpen
} = this.state;
const selectedIds = this.getSelectedIds();
return (
<PageContent title={translate('Blocklist')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={!selectedIds.length}
isSpinning={isRemoving}
onPress={this.onRemoveSelectedPress}
/>
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!items.length}
isSpinning={isClearingBlocklistExecuting}
onPress={this.onClearBlocklistPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={BlocklistFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>
{translate('BlocklistLoadError')}
</Alert>
}
{
isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}>
{
selectedFilterKey === 'all' ?
translate('NoHistoryBlocklist') :
translate('BlocklistFilterHasNoItems')
}
</Alert>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<BlocklistRowConnector
key={item.id}
isSelected={selectedState[item.id] || false}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
</div>
}
</PageContentBody>
<ConfirmModal
isOpen={isConfirmRemoveModalOpen}
kind={kinds.DANGER}
title={translate('RemoveSelected')}
message={translate('RemoveSelectedBlocklistMessageText')}
confirmLabel={translate('RemoveSelected')}
onConfirm={this.onRemoveSelectedConfirmed}
onCancel={this.onConfirmRemoveModalClose}
/>
<ConfirmModal
isOpen={isConfirmClearModalOpen}
kind={kinds.DANGER}
title={translate('ClearBlocklist')}
message={translate('ClearBlocklistMessageText')}
confirmLabel={translate('Clear')}
onConfirm={this.onClearBlocklistConfirmed}
onCancel={this.onConfirmClearModalClose}
/>
</PageContent>
);
}
}
Blocklist.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isRemoving: PropTypes.bool.isRequired,
isClearingBlocklistExecuting: PropTypes.bool.isRequired,
onRemoveSelected: PropTypes.func.isRequired,
onClearBlocklistPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
export default Blocklist;

View File

@@ -0,0 +1,329 @@
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 Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props';
import {
clearBlocklist,
fetchBlocklist,
gotoBlocklistPage,
removeBlocklistItems,
setBlocklistFilter,
setBlocklistSort,
setBlocklistTableOption,
} from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
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 BlocklistRow from './BlocklistRow';
function Blocklist() {
const requestCurrentPage = useCurrentPage();
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
isRemoving,
} = useSelector((state: AppState) => state.blocklist);
const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
const isClearingBlocklistExecuting = useSelector(
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST)
);
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 handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const handleRemoveSelectedPress = useCallback(() => {
setIsConfirmRemoveModalOpen(true);
}, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback(() => {
dispatch(removeBlocklistItems({ ids: selectedIds }));
setIsConfirmRemoveModalOpen(false);
}, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]);
const handleConfirmRemoveModalClose = useCallback(() => {
setIsConfirmRemoveModalOpen(false);
}, [setIsConfirmRemoveModalOpen]);
const handleClearBlocklistPress = useCallback(() => {
setIsConfirmClearModalOpen(true);
}, [setIsConfirmClearModalOpen]);
const handleClearBlocklistConfirmed = useCallback(() => {
dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST }));
setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen, dispatch]);
const handleConfirmClearModalClose = useCallback(() => {
setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoBlocklistPage,
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
dispatch(setBlocklistFilter({ selectedFilterKey }));
},
[dispatch]
);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setBlocklistSort({ sortKey }));
},
[dispatch]
);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setBlocklistTableOption(payload));
if (payload.pageSize) {
dispatch(gotoBlocklistPage({ page: 1 }));
}
},
[dispatch]
);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchBlocklist());
} else {
dispatch(gotoBlocklistPage({ page: 1 }));
}
return () => {
dispatch(clearBlocklist());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchBlocklist());
};
registerPagePopulator(repopulate);
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
useEffect(() => {
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
dispatch(gotoBlocklistPage({ page: 1 }));
}
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
return (
<SelectProvider items={items}>
<PageContent title={translate('Blocklist')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={!selectedIds.length}
isSpinning={isRemoving}
onPress={handleRemoveSelectedPress}
/>
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!items.length}
isSpinning={isClearingBlocklistExecuting}
onPress={handleClearBlocklistPress}
/>
</PageToolbarSection>
<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>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
) : null}
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey === 'all'
? translate('NoBlocklistItems')
: translate('BlocklistFilterHasNoItems')}
</Alert>
) : null}
{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>
</SelectProvider>
);
}
export default Blocklist;

View File

@@ -1,161 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage';
import * as blocklistActions from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Blocklist from './Blocklist';
function createMapStateToProps() {
return createSelector(
(state) => state.blocklist,
createCustomFiltersSelector('blocklist'),
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST),
(blocklist, customFilters, isClearingBlocklistExecuting) => {
return {
isClearingBlocklistExecuting,
customFilters,
...blocklist
};
}
);
}
const mapDispatchToProps = {
...blocklistActions,
executeCommand
};
class BlocklistConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchBlocklist,
gotoBlocklistFirstPage
} = this.props;
registerPagePopulator(this.repopulate);
if (useCurrentPage) {
fetchBlocklist();
} else {
gotoBlocklistFirstPage();
}
}
componentDidUpdate(prevProps) {
if (prevProps.isClearingBlocklistExecuting && !this.props.isClearingBlocklistExecuting) {
this.props.gotoBlocklistFirstPage();
}
}
componentWillUnmount() {
this.props.clearBlocklist();
unregisterPagePopulator(this.repopulate);
}
//
// Control
repopulate = () => {
this.props.fetchBlocklist();
};
//
// Listeners
onFirstPagePress = () => {
this.props.gotoBlocklistFirstPage();
};
onPreviousPagePress = () => {
this.props.gotoBlocklistPreviousPage();
};
onNextPagePress = () => {
this.props.gotoBlocklistNextPage();
};
onLastPagePress = () => {
this.props.gotoBlocklistLastPage();
};
onPageSelect = (page) => {
this.props.gotoBlocklistPage({ page });
};
onRemoveSelected = (ids) => {
this.props.removeBlocklistItems({ ids });
};
onSortPress = (sortKey) => {
this.props.setBlocklistSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setBlocklistFilter({ selectedFilterKey });
};
onClearBlocklistPress = () => {
this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST });
};
onTableOptionChange = (payload) => {
this.props.setBlocklistTableOption(payload);
if (payload.pageSize) {
this.props.gotoBlocklistFirstPage();
}
};
//
// Render
render() {
return (
<Blocklist
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onRemoveSelected={this.onRemoveSelected}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onClearBlocklistPress={this.onClearBlocklistPress}
{...this.props}
/>
);
}
}
BlocklistConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
isClearingBlocklistExecuting: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchBlocklist: PropTypes.func.isRequired,
gotoBlocklistFirstPage: PropTypes.func.isRequired,
gotoBlocklistPreviousPage: PropTypes.func.isRequired,
gotoBlocklistNextPage: PropTypes.func.isRequired,
gotoBlocklistLastPage: PropTypes.func.isRequired,
gotoBlocklistPage: PropTypes.func.isRequired,
removeBlocklistItems: PropTypes.func.isRequired,
setBlocklistSort: PropTypes.func.isRequired,
setBlocklistFilter: PropTypes.func.isRequired,
setBlocklistTableOption: PropTypes.func.isRequired,
clearBlocklist: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(BlocklistConnector)
);

View File

@@ -1,90 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import translate from 'Utilities/String/translate';
class BlocklistDetailsModal extends Component {
//
// Render
render() {
const {
isOpen,
sourceTitle,
protocol,
indexer,
message,
onModalClose
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
Details
</ModalHeader>
<ModalBody>
<DescriptionList>
<DescriptionListItem
title={translate('Name')}
data={sourceTitle}
/>
<DescriptionListItem
title={translate('Protocol')}
data={protocol}
/>
{
!!message &&
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/>
}
{
!!message &&
<DescriptionListItem
title={translate('Message')}
data={message}
/>
}
</DescriptionList>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
BlocklistDetailsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
sourceTitle: PropTypes.string.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
message: PropTypes.string,
onModalClose: PropTypes.func.isRequired
};
export default BlocklistDetailsModal;

View File

@@ -0,0 +1,64 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import translate from 'Utilities/String/translate';
interface BlocklistDetailsModalProps {
isOpen: boolean;
sourceTitle: string;
protocol: DownloadProtocol;
indexer?: string;
message?: string;
onModalClose: () => void;
}
function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } =
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Details</ModalHeader>
<ModalBody>
<DescriptionList>
<DescriptionListItem title={translate('Name')} data={sourceTitle} />
<DescriptionListItem
title={translate('Protocol')}
data={protocol}
/>
{message ? (
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/>
) : null}
{message ? (
<DescriptionListItem
title={translate('Message')}
data={message}
/>
) : null}
</DescriptionList>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
export default BlocklistDetailsModal;

View File

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
function createBlocklistSelector() {
@@ -23,9 +23,7 @@ function createFilterBuilderPropsSelector() {
);
}
interface BlocklistFilterModalProps {
isOpen: boolean;
}
type BlocklistFilterModalProps = FilterModalProps<History>;
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
const sectionItems = useSelector(createBlocklistSelector());
@@ -43,7 +41,6 @@ export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}

View File

@@ -1,212 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds } from 'Helpers/Props';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import translate from 'Utilities/String/translate';
import BlocklistDetailsModal from './BlocklistDetailsModal';
import styles from './BlocklistRow.css';
class BlocklistRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onDetailsPress = () => {
this.setState({ isDetailsModalOpen: true });
};
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
};
//
// Render
render() {
const {
id,
series,
sourceTitle,
languages,
quality,
customFormats,
date,
protocol,
indexer,
message,
isSelected,
columns,
onSelectedChange,
onRemovePress
} = this.props;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'sourceTitle') {
return (
<TableRowCell key={name}>
{sourceTitle}
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell
key={name}
className={styles.languages}
>
<EpisodeLanguages
languages={languages}
/>
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell
key={name}
className={styles.quality}
>
<EpisodeQuality
quality={quality}
/>
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'date') {
return (
<RelativeDateCellConnector
key={name}
date={date}
/>
);
}
if (name === 'indexer') {
return (
<TableRowCell
key={name}
className={styles.indexer}
>
{indexer}
</TableRowCell>
);
}
if (name === 'actions') {
return (
<TableRowCell
key={name}
className={styles.actions}
>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
<IconButton
title={translate('RemoveFromBlocklist')}
name={icons.REMOVE}
kind={kinds.DANGER}
onPress={onRemovePress}
/>
</TableRowCell>
);
}
return null;
})
}
<BlocklistDetailsModal
isOpen={this.state.isDetailsModalOpen}
sourceTitle={sourceTitle}
protocol={protocol}
indexer={indexer}
message={message}
onModalClose={this.onDetailsModalClose}
/>
</TableRow>
);
}
}
BlocklistRow.propTypes = {
id: PropTypes.number.isRequired,
series: PropTypes.object.isRequired,
sourceTitle: PropTypes.string.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object).isRequired,
date: PropTypes.string.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
message: PropTypes.string,
isSelected: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelectedChange: PropTypes.func.isRequired,
onRemovePress: PropTypes.func.isRequired
};
export default BlocklistRow;

View File

@@ -0,0 +1,163 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import EpisodeFormats from 'Episode/EpisodeFormats';
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 Blocklist from 'typings/Blocklist';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import BlocklistDetailsModal from './BlocklistDetailsModal';
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();
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const handleDetailsPress = useCallback(() => {
setIsDetailsModalOpen(true);
}, [setIsDetailsModalOpen]);
const handleDetailsModalClose = useCallback(() => {
setIsDetailsModalOpen(false);
}, [setIsDetailsModalOpen]);
const handleRemovePress = useCallback(() => {
dispatch(removeBlocklistItem({ id }));
}, [id, dispatch]);
if (!series) {
return null;
}
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'sourceTitle') {
return <TableRowCell key={name}>{sourceTitle}</TableRowCell>;
}
if (name === 'languages') {
return (
<TableRowCell key={name} className={styles.languages}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name} className={styles.quality}>
<EpisodeQuality quality={quality} />
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
);
}
if (name === 'date') {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(2739)
return <RelativeDateCell key={name} date={date} />;
}
if (name === 'indexer') {
return (
<TableRowCell key={name} className={styles.indexer}>
{indexer}
</TableRowCell>
);
}
if (name === 'actions') {
return (
<TableRowCell key={name} className={styles.actions}>
<IconButton name={icons.INFO} onPress={handleDetailsPress} />
<IconButton
title={translate('RemoveFromBlocklist')}
name={icons.REMOVE}
kind={kinds.DANGER}
onPress={handleRemovePress}
/>
</TableRowCell>
);
}
return null;
})}
<BlocklistDetailsModal
isOpen={isDetailsModalOpen}
sourceTitle={sourceTitle}
protocol={protocol}
indexer={indexer}
message={message}
onModalClose={handleDetailsModalClose}
/>
</TableRow>
);
}
export default BlocklistRow;

View File

@@ -1,26 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { removeBlocklistItem } from 'Store/Actions/blocklistActions';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import BlocklistRow from './BlocklistRow';
function createMapStateToProps() {
return createSelector(
createSeriesSelector(),
(series) => {
return {
series
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onRemovePress() {
dispatch(removeBlocklistItem({ id: props.id }));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(BlocklistRow);

View File

@@ -1,354 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
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 formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './HistoryDetails.css';
function HistoryDetails(props) {
const {
eventType,
sourceTitle,
data,
downloadId,
shortDateFormat,
timeFormat
} = props;
if (eventType === 'grabbed') {
const {
indexer,
releaseGroup,
seriesMatchType,
customFormatScore,
nzbInfoUrl,
downloadClient,
downloadClientName,
age,
ageHours,
ageMinutes,
publishedDate
} = data;
const downloadClientNameInfo = downloadClientName ?? downloadClient;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{
indexer ?
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/> :
null
}
{
releaseGroup ?
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseGroup')}
data={releaseGroup}
/> :
null
}
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(customFormatScore)}
/> :
null
}
{
seriesMatchType ?
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('SeriesMatchType')}
data={seriesMatchType}
/> :
null
}
{
nzbInfoUrl ?
<span>
<DescriptionListItemTitle>
{translate('InfoUrl')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
</DescriptionListItemDescription>
</span> :
null
}
{
downloadClientNameInfo ?
<DescriptionListItem
title={translate('DownloadClient')}
data={downloadClientNameInfo}
/> :
null
}
{
downloadId ?
<DescriptionListItem
title={translate('GrabId')}
data={downloadId}
/> :
null
}
{
age || ageHours || ageMinutes ?
<DescriptionListItem
title={translate('AgeWhenGrabbed')}
data={formatAge(age, ageHours, ageMinutes)}
/> :
null
}
{
publishedDate ?
<DescriptionListItem
title={translate('PublishedDate')}
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, { includeSeconds: true })}
/> :
null
}
</DescriptionList>
);
}
if (eventType === 'downloadFailed') {
const {
message
} = data;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{
downloadId ?
<DescriptionListItem
title={translate('GrabId')}
data={downloadId}
/> :
null
}
{
message ?
<DescriptionListItem
title={translate('Message')}
data={message}
/> :
null
}
</DescriptionList>
);
}
if (eventType === 'downloadFolderImported') {
const {
customFormatScore,
droppedPath,
importedPath
} = data;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{
droppedPath ?
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Source')}
data={droppedPath}
/> :
null
}
{
importedPath ?
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ImportedTo')}
data={importedPath}
/> :
null
}
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(customFormatScore)}
/> :
null
}
</DescriptionList>
);
}
if (eventType === 'episodeFileDeleted') {
const {
reason,
customFormatScore
} = data;
let reasonMessage = '';
switch (reason) {
case 'Manual':
reasonMessage = translate('DeletedReasonManual');
break;
case 'MissingFromDisk':
reasonMessage = translate('DeletedReasonEpisodeMissingFromDisk');
break;
case 'Upgrade':
reasonMessage = translate('DeletedReasonUpgrade');
break;
default:
reasonMessage = '';
}
return (
<DescriptionList>
<DescriptionListItem
title={translate('Name')}
data={sourceTitle}
/>
<DescriptionListItem
title={translate('Reason')}
data={reasonMessage}
/>
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(customFormatScore)}
/> :
null
}
</DescriptionList>
);
}
if (eventType === 'episodeFileRenamed') {
const {
sourcePath,
sourceRelativePath,
path,
relativePath
} = data;
return (
<DescriptionList>
<DescriptionListItem
title={translate('SourcePath')}
data={sourcePath}
/>
<DescriptionListItem
title={translate('SourceRelativePath')}
data={sourceRelativePath}
/>
<DescriptionListItem
title={translate('DestinationPath')}
data={path}
/>
<DescriptionListItem
title={translate('DestinationRelativePath')}
data={relativePath}
/>
</DescriptionList>
);
}
if (eventType === 'downloadIgnored') {
const {
message
} = data;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{
downloadId ?
<DescriptionListItem
title={translate('GrabId')}
data={downloadId}
/> :
null
}
{
message ?
<DescriptionListItem
title={translate('Message')}
data={message}
/> :
null
}
</DescriptionList>
);
}
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
</DescriptionList>
);
}
HistoryDetails.propTypes = {
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
downloadId: PropTypes.string,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default HistoryDetails;

View File

@@ -0,0 +1,345 @@
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 {
DownloadFailedHistory,
DownloadFolderImportedHistory,
DownloadIgnoredHistory,
EpisodeFileDeletedHistory,
EpisodeFileRenamedHistory,
GrabbedHistoryData,
HistoryData,
HistoryEventType,
} from 'typings/History';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './HistoryDetails.css';
interface HistoryDetailsProps {
eventType: HistoryEventType;
sourceTitle: string;
data: HistoryData;
downloadId?: string;
}
function HistoryDetails(props: HistoryDetailsProps) {
const { eventType, sourceTitle, data, downloadId } = props;
const { shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
if (eventType === 'grabbed') {
const {
indexer,
releaseGroup,
seriesMatchType,
releaseSource,
customFormatScore,
nzbInfoUrl,
downloadClient,
downloadClientName,
age,
ageHours,
ageMinutes,
publishedDate,
size,
} = data as GrabbedHistoryData;
const downloadClientNameInfo = downloadClientName ?? downloadClient;
let releaseSourceMessage = '';
switch (releaseSource) {
case 'Unknown':
releaseSourceMessage = translate('Unknown');
break;
case 'Rss':
releaseSourceMessage = translate('Rss');
break;
case 'Search':
releaseSourceMessage = translate('Search');
break;
case 'UserInvokedSearch':
releaseSourceMessage = translate('UserInvokedSearch');
break;
case 'InteractiveSearch':
releaseSourceMessage = translate('InteractiveSearch');
break;
case 'ReleasePush':
releaseSourceMessage = translate('ReleasePush');
break;
default:
releaseSourceMessage = '';
}
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{indexer ? (
<DescriptionListItem title={translate('Indexer')} data={indexer} />
) : null}
{releaseGroup ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseGroup')}
data={releaseGroup}
/>
) : null}
{customFormatScore && customFormatScore !== '0' ? (
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
{seriesMatchType ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('SeriesMatchType')}
data={seriesMatchType}
/>
) : null}
{releaseSource ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseSource')}
data={releaseSourceMessage}
/>
) : null}
{nzbInfoUrl ? (
<span>
<DescriptionListItemTitle>
{translate('InfoUrl')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
</DescriptionListItemDescription>
</span>
) : null}
{downloadClientNameInfo ? (
<DescriptionListItem
title={translate('DownloadClient')}
data={downloadClientNameInfo}
/>
) : null}
{downloadId ? (
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
) : null}
{age || ageHours || ageMinutes ? (
<DescriptionListItem
title={translate('AgeWhenGrabbed')}
data={formatAge(age, ageHours, ageMinutes)}
/>
) : null}
{publishedDate ? (
<DescriptionListItem
title={translate('PublishedDate')}
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, {
includeSeconds: true,
})}
/>
) : null}
{size ? (
<DescriptionListItem
title={translate('Size')}
data={formatBytes(size)}
/>
) : null}
</DescriptionList>
);
}
if (eventType === 'downloadFailed') {
const { message } = data as DownloadFailedHistory;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{downloadId ? (
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
) : null}
{message ? (
<DescriptionListItem title={translate('Message')} data={message} />
) : null}
</DescriptionList>
);
}
if (eventType === 'downloadFolderImported') {
const { customFormatScore, droppedPath, importedPath, size } =
data as DownloadFolderImportedHistory;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{droppedPath ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Source')}
data={droppedPath}
/>
) : null}
{importedPath ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ImportedTo')}
data={importedPath}
/>
) : null}
{customFormatScore && customFormatScore !== '0' ? (
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
{size ? (
<DescriptionListItem
title={translate('FileSize')}
data={formatBytes(size)}
/>
) : null}
</DescriptionList>
);
}
if (eventType === 'episodeFileDeleted') {
const { reason, customFormatScore, size } =
data as EpisodeFileDeletedHistory;
let reasonMessage = '';
switch (reason) {
case 'Manual':
reasonMessage = translate('DeletedReasonManual');
break;
case 'MissingFromDisk':
reasonMessage = translate('DeletedReasonEpisodeMissingFromDisk');
break;
case 'Upgrade':
reasonMessage = translate('DeletedReasonUpgrade');
break;
default:
reasonMessage = '';
}
return (
<DescriptionList>
<DescriptionListItem title={translate('Name')} data={sourceTitle} />
<DescriptionListItem title={translate('Reason')} data={reasonMessage} />
{customFormatScore && customFormatScore !== '0' ? (
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
{size ? (
<DescriptionListItem
title={translate('FileSize')}
data={formatBytes(size)}
/>
) : null}
</DescriptionList>
);
}
if (eventType === 'episodeFileRenamed') {
const { sourcePath, sourceRelativePath, path, relativePath } =
data as EpisodeFileRenamedHistory;
return (
<DescriptionList>
<DescriptionListItem
title={translate('SourcePath')}
data={sourcePath}
/>
<DescriptionListItem
title={translate('SourceRelativePath')}
data={sourceRelativePath}
/>
<DescriptionListItem title={translate('DestinationPath')} data={path} />
<DescriptionListItem
title={translate('DestinationRelativePath')}
data={relativePath}
/>
</DescriptionList>
);
}
if (eventType === 'downloadIgnored') {
const { message } = data as DownloadIgnoredHistory;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{downloadId ? (
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
) : null}
{message ? (
<DescriptionListItem title={translate('Message')} data={message} />
) : null}
</DescriptionList>
);
}
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
</DescriptionList>
);
}
export default HistoryDetails;

View File

@@ -1,19 +0,0 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import HistoryDetails from './HistoryDetails';
function createMapStateToProps() {
return createSelector(
createUISettingsSelector(),
(uiSettings) => {
return _.pick(uiSettings, [
'shortDateFormat',
'timeFormat'
]);
}
);
}
export default connect(createMapStateToProps)(HistoryDetails);

View File

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
@@ -8,11 +7,12 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import { HistoryData, HistoryEventType } from 'typings/History';
import translate from 'Utilities/String/translate';
import HistoryDetails from './HistoryDetails';
import styles from './HistoryDetailsModal.css';
function getHeaderTitle(eventType) {
function getHeaderTitle(eventType: HistoryEventType) {
switch (eventType) {
case 'grabbed':
return translate('Grabbed');
@@ -31,29 +31,33 @@ function getHeaderTitle(eventType) {
}
}
function HistoryDetailsModal(props) {
interface HistoryDetailsModalProps {
isOpen: boolean;
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,
shortDateFormat,
timeFormat,
isMarkingAsFailed = false,
onMarkAsFailedPress,
onModalClose
onModalClose,
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{getHeaderTitle(eventType)}
</ModalHeader>
<ModalHeader>{getHeaderTitle(eventType)}</ModalHeader>
<ModalBody>
<HistoryDetails
@@ -61,50 +65,26 @@ function HistoryDetailsModal(props) {
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
</ModalBody>
<ModalFooter>
{
eventType === 'grabbed' &&
<SpinnerButton
className={styles.markAsFailedButton}
kind={kinds.DANGER}
isSpinning={isMarkingAsFailed}
onPress={onMarkAsFailedPress}
>
{translate('MarkAsFailed')}
</SpinnerButton>
}
{eventType === 'grabbed' && (
<SpinnerButton
className={styles.markAsFailedButton}
kind={kinds.DANGER}
isSpinning={isMarkingAsFailed}
onPress={onMarkAsFailedPress}
>
{translate('MarkAsFailed')}
</SpinnerButton>
)}
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
HistoryDetailsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
downloadId: PropTypes.string,
isMarkingAsFailed: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
HistoryDetailsModal.defaultProps = {
isMarkingAsFailed: false
};
export default HistoryDetailsModal;

View File

@@ -1,180 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import 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 { align, icons, kinds } from 'Helpers/Props';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import translate from 'Utilities/String/translate';
import HistoryFilterModal from './HistoryFilterModal';
import HistoryRowConnector from './HistoryRowConnector';
class History extends Component {
//
// Lifecycle
shouldComponentUpdate(nextProps) {
// Don't update when fetching has completed if items have changed,
// before episodes start fetching or when episodes start fetching.
if (
(
this.props.isFetching &&
nextProps.isPopulated &&
hasDifferentItems(this.props.items, nextProps.items)
) ||
(!this.props.isEpisodesFetching && nextProps.isEpisodesFetching)
) {
return false;
}
return true;
}
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
customFilters,
totalRecords,
isEpisodesFetching,
isEpisodesPopulated,
episodesError,
onFilterSelect,
onFirstPagePress,
...otherProps
} = this.props;
const isFetchingAny = isFetching || isEpisodesFetching;
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
const hasError = error || episodesError;
return (
<PageContent title={translate('History')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('Refresh')}
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={onFirstPagePress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={HistoryFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetchingAny && !isAllPopulated &&
<LoadingIndicator />
}
{
!isFetchingAny && hasError &&
<Alert kind={kinds.DANGER}>
{translate('HistoryLoadError')}
</Alert>
}
{
// 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 &&
<Alert kind={kinds.INFO}>
{translate('NoHistoryFound')}
</Alert>
}
{
isAllPopulated && !hasError && !!items.length &&
<div>
<Table
columns={columns}
{...otherProps}
>
<TableBody>
{
items.map((item) => {
return (
<HistoryRowConnector
key={item.id}
columns={columns}
{...item}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetchingAny}
onFirstPagePress={onFirstPagePress}
{...otherProps}
/>
</div>
}
</PageContentBody>
</PageContent>
);
}
}
History.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isEpisodesFetching: PropTypes.bool.isRequired,
isEpisodesPopulated: PropTypes.bool.isRequired,
episodesError: PropTypes.object,
onFilterSelect: PropTypes.func.isRequired,
onFirstPagePress: PropTypes.func.isRequired
};
export default History;

View File

@@ -0,0 +1,231 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import 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 { 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 HistoryItem from 'typings/History';
import { TableOptionsChangePayload } from 'typings/Table';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import HistoryFilterModal from './HistoryFilterModal';
import HistoryRow from './HistoryRow';
function History() {
const requestCurrentPage = useCurrentPage();
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
} = useSelector((state: AppState) => state.history);
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('history'));
const dispatch = useDispatch();
const isFetchingAny = isFetching || isEpisodesFetching;
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
const hasError = error || episodesError;
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoHistoryPage,
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
dispatch(setHistoryFilter({ selectedFilterKey }));
},
[dispatch]
);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setHistorySort({ sortKey }));
},
[dispatch]
);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setHistoryTableOption(payload));
if (payload.pageSize) {
dispatch(gotoHistoryPage({ page: 1 }));
}
},
[dispatch]
);
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]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchHistory());
};
registerPagePopulator(repopulate);
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
return (
<PageContent title={translate('History')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('Refresh')}
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={handleFirstPagePress}
/>
</PageToolbarSection>
<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={HistoryFilterModal}
onFilterSelect={handleFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{isFetchingAny && !isAllPopulated ? <LoadingIndicator /> : null}
{!isFetchingAny && hasError ? (
<Alert kind={kinds.DANGER}>{translate('HistoryLoadError')}</Alert>
) : null}
{
// 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 ? (
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
) : null
}
{isAllPopulated && !hasError && items.length ? (
<div>
<Table
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
return (
<HistoryRow key={item.id} columns={columns} {...item} />
);
})}
</TableBody>
</Table>
<TablePager
page={page}
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
</div>
) : null}
</PageContentBody>
</PageContent>
);
}
export default History;

View File

@@ -1,165 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import withCurrentPage from 'Components/withCurrentPage';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
import * as historyActions from 'Store/Actions/historyActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import History from './History';
function createMapStateToProps() {
return createSelector(
(state) => state.history,
(state) => state.episodes,
createCustomFiltersSelector('history'),
(history, episodes, customFilters) => {
return {
isEpisodesFetching: episodes.isFetching,
isEpisodesPopulated: episodes.isPopulated,
episodesError: episodes.error,
customFilters,
...history
};
}
);
}
const mapDispatchToProps = {
...historyActions,
fetchEpisodes,
clearEpisodes,
clearEpisodeFiles
};
class HistoryConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchHistory,
gotoHistoryFirstPage
} = this.props;
registerPagePopulator(this.repopulate);
if (useCurrentPage) {
fetchHistory();
} else {
gotoHistoryFirstPage();
}
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const episodeIds = selectUniqueIds(this.props.items, 'episodeId');
if (episodeIds.length) {
this.props.fetchEpisodes({ episodeIds });
} else {
this.props.clearEpisodes();
}
}
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
this.props.clearHistory();
this.props.clearEpisodes();
this.props.clearEpisodeFiles();
}
//
// Control
repopulate = () => {
this.props.fetchHistory();
};
//
// Listeners
onFirstPagePress = () => {
this.props.gotoHistoryFirstPage();
};
onPreviousPagePress = () => {
this.props.gotoHistoryPreviousPage();
};
onNextPagePress = () => {
this.props.gotoHistoryNextPage();
};
onLastPagePress = () => {
this.props.gotoHistoryLastPage();
};
onPageSelect = (page) => {
this.props.gotoHistoryPage({ page });
};
onSortPress = (sortKey) => {
this.props.setHistorySort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setHistoryFilter({ selectedFilterKey });
};
onTableOptionChange = (payload) => {
this.props.setHistoryTableOption(payload);
if (payload.pageSize) {
this.props.gotoHistoryFirstPage();
}
};
//
// Render
render() {
return (
<History
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
{...this.props}
/>
);
}
}
HistoryConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchHistory: PropTypes.func.isRequired,
gotoHistoryFirstPage: PropTypes.func.isRequired,
gotoHistoryPreviousPage: PropTypes.func.isRequired,
gotoHistoryNextPage: PropTypes.func.isRequired,
gotoHistoryLastPage: PropTypes.func.isRequired,
gotoHistoryPage: PropTypes.func.isRequired,
setHistorySort: PropTypes.func.isRequired,
setHistoryFilter: PropTypes.func.isRequired,
setHistoryTableOption: PropTypes.func.isRequired,
clearHistory: PropTypes.func.isRequired,
fetchEpisodes: PropTypes.func.isRequired,
clearEpisodes: PropTypes.func.isRequired,
clearEpisodeFiles: PropTypes.func.isRequired
};
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector)
);

View File

@@ -1,12 +1,17 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons, kinds } from 'Helpers/Props';
import {
EpisodeFileDeletedHistory,
GrabbedHistoryData,
HistoryData,
HistoryEventType,
} from 'typings/History';
import translate from 'Utilities/String/translate';
import styles from './HistoryEventTypeCell.css';
function getIconName(eventType, data) {
function getIconName(eventType: HistoryEventType, data: HistoryData) {
switch (eventType) {
case 'grabbed':
return icons.DOWNLOADING;
@@ -17,7 +22,9 @@ function getIconName(eventType, data) {
case 'downloadFailed':
return icons.DOWNLOADING;
case 'episodeFileDeleted':
return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE;
return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk'
? icons.FILE_MISSING
: icons.DELETE;
case 'episodeFileRenamed':
return icons.ORGANIZE;
case 'downloadIgnored':
@@ -27,7 +34,7 @@ function getIconName(eventType, data) {
}
}
function getIconKind(eventType) {
function getIconKind(eventType: HistoryEventType) {
switch (eventType) {
case 'downloadFailed':
return kinds.DANGER;
@@ -36,10 +43,13 @@ function getIconKind(eventType) {
}
}
function getTooltip(eventType, data) {
function getTooltip(eventType: HistoryEventType, data: HistoryData) {
switch (eventType) {
case 'grabbed':
return translate('EpisodeGrabbedTooltip', { indexer: data.indexer, downloadClient: data.downloadClient });
return translate('EpisodeGrabbedTooltip', {
indexer: (data as GrabbedHistoryData).indexer,
downloadClient: (data as GrabbedHistoryData).downloadClient,
});
case 'seriesFolderImported':
return translate('SeriesFolderImportedTooltip');
case 'downloadFolderImported':
@@ -47,7 +57,9 @@ function getTooltip(eventType, data) {
case 'downloadFailed':
return translate('DownloadFailedEpisodeTooltip');
case 'episodeFileDeleted':
return data.reason === 'MissingFromDisk' ? translate('EpisodeFileMissingTooltip') : translate('EpisodeFileDeletedTooltip');
return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk'
? translate('EpisodeFileMissingTooltip')
: translate('EpisodeFileDeletedTooltip');
case 'episodeFileRenamed':
return translate('EpisodeFileRenamedTooltip');
case 'downloadIgnored':
@@ -57,31 +69,21 @@ function getTooltip(eventType, data) {
}
}
function HistoryEventTypeCell({ eventType, data }) {
interface HistoryEventTypeCellProps {
eventType: HistoryEventType;
data: HistoryData;
}
function HistoryEventTypeCell({ eventType, data }: HistoryEventTypeCellProps) {
const iconName = getIconName(eventType, data);
const iconKind = getIconKind(eventType);
const tooltip = getTooltip(eventType, data);
return (
<TableRowCell
className={styles.cell}
title={tooltip}
>
<Icon
name={iconName}
kind={iconKind}
/>
<TableRowCell className={styles.cell} title={tooltip}>
<Icon name={iconName} kind={iconKind} />
</TableRowCell>
);
}
HistoryEventTypeCell.propTypes = {
eventType: PropTypes.string.isRequired,
data: PropTypes.object
};
HistoryEventTypeCell.defaultProps = {
data: {}
};
export default HistoryEventTypeCell;

View File

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

View File

@@ -1,312 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Tooltip from 'Components/Tooltip/Tooltip';
import episodeEntities from 'Episode/episodeEntities';
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 { icons, tooltipPositions } from 'Helpers/Props';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import HistoryDetailsModal from './Details/HistoryDetailsModal';
import HistoryEventTypeCell from './HistoryEventTypeCell';
import styles from './HistoryRow.css';
class HistoryRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (
prevProps.isMarkingAsFailed &&
!this.props.isMarkingAsFailed &&
!this.props.markAsFailedError
) {
this.setState({ isDetailsModalOpen: false });
}
}
//
// Listeners
onDetailsPress = () => {
this.setState({ isDetailsModalOpen: true });
};
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
};
//
// Render
render() {
const {
episodeId,
series,
episode,
languages,
quality,
customFormats,
customFormatScore,
qualityCutoffNotMet,
eventType,
sourceTitle,
date,
data,
downloadId,
isMarkingAsFailed,
columns,
shortDateFormat,
timeFormat,
onMarkAsFailedPress
} = this.props;
if (!episode) {
return null;
}
return (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'eventType') {
return (
<HistoryEventTypeCell
key={name}
eventType={eventType}
data={data}
/>
);
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'episode') {
return (
<TableRowCell key={name}>
<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}
/>
</TableRowCell>
);
}
if (name === 'episodes.title') {
return (
<TableRowCell key={name}>
<EpisodeTitleLink
episodeId={episodeId}
episodeEntity={episodeEntities.EPISODES}
seriesId={series.id}
episodeTitle={episode.title}
showOpenSeriesButton={true}
/>
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell key={name}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
<EpisodeQuality
quality={quality}
isCutoffMet={qualityCutoffNotMet}
/>
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'date') {
return (
<RelativeDateCellConnector
key={name}
date={date}
/>
);
}
if (name === 'downloadClient') {
return (
<TableRowCell
key={name}
className={styles.downloadClient}
>
{data.downloadClient}
</TableRowCell>
);
}
if (name === 'indexer') {
return (
<TableRowCell
key={name}
className={styles.indexer}
>
{data.indexer}
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'releaseGroup') {
return (
<TableRowCell
key={name}
className={styles.releaseGroup}
>
{data.releaseGroup}
</TableRowCell>
);
}
if (name === 'sourceTitle') {
return (
<TableRowCell
key={name}
>
{sourceTitle}
</TableRowCell>
);
}
if (name === 'details') {
return (
<TableRowCell
key={name}
className={styles.details}
>
<div className={styles.actionContents}>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
</div>
</TableRowCell>
);
}
return null;
})
}
<HistoryDetailsModal
isOpen={this.state.isDetailsModalOpen}
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
onMarkAsFailedPress={onMarkAsFailedPress}
onModalClose={this.onDetailsModalClose}
/>
</TableRow>
);
}
}
HistoryRow.propTypes = {
episodeId: PropTypes.number,
series: PropTypes.object.isRequired,
episode: PropTypes.object,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired,
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
downloadId: PropTypes.string,
isMarkingAsFailed: PropTypes.bool,
markAsFailedError: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired
};
HistoryRow.defaultProps = {
customFormats: []
};
export default HistoryRow;

View File

@@ -0,0 +1,270 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import Tooltip from 'Components/Tooltip/Tooltip';
import episodeEntities from 'Episode/episodeEntities';
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 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 CustomFormat from 'typings/CustomFormat';
import { HistoryData, HistoryEventType } from 'typings/History';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import HistoryDetailsModal from './Details/HistoryDetailsModal';
import HistoryEventTypeCell from './HistoryEventTypeCell';
import styles from './HistoryRow.css';
interface HistoryRowProps {
id: number;
episodeId: number;
seriesId: number;
languages: Language[];
quality: QualityModel;
customFormats?: CustomFormat[];
customFormatScore: number;
qualityCutoffNotMet: boolean;
eventType: HistoryEventType;
sourceTitle: string;
date: string;
data: HistoryData;
downloadId?: string;
isMarkingAsFailed?: boolean;
markAsFailedError?: object;
columns: Column[];
}
function HistoryRow(props: HistoryRowProps) {
const {
id,
episodeId,
seriesId,
languages,
quality,
customFormats = [],
customFormatScore,
qualityCutoffNotMet,
eventType,
sourceTitle,
date,
data,
downloadId,
isMarkingAsFailed = false,
markAsFailedError,
columns,
} = props;
const wasMarkingAsFailed = usePrevious(isMarkingAsFailed);
const dispatch = useDispatch();
const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes');
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const handleDetailsPress = useCallback(() => {
setIsDetailsModalOpen(true);
}, [setIsDetailsModalOpen]);
const handleDetailsModalClose = useCallback(() => {
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;
}
return (
<TableRow>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'eventType') {
return (
<HistoryEventTypeCell
key={name}
eventType={eventType}
data={data}
/>
);
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'episode') {
return (
<TableRowCell key={name}>
<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}
/>
</TableRowCell>
);
}
if (name === 'episodes.title') {
return (
<TableRowCell key={name}>
<EpisodeTitleLink
episodeId={episodeId}
episodeEntity={episodeEntities.EPISODES}
seriesId={series.id}
episodeTitle={episode.title}
showOpenSeriesButton={true}
/>
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell key={name}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
);
}
if (name === 'date') {
return <RelativeDateCell key={name} date={date} />;
}
if (name === 'downloadClient') {
const downloadClientName =
'downloadClientName' in data ? data.downloadClientName : null;
const downloadClient =
'downloadClient' in data ? data.downloadClient : null;
return (
<TableRowCell key={name} className={styles.downloadClient}>
{downloadClientName ?? downloadClient ?? ''}
</TableRowCell>
);
}
if (name === 'indexer') {
return (
<TableRowCell key={name} className={styles.indexer}>
{'indexer' in data ? data.indexer : ''}
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell key={name} className={styles.customFormatScore}>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'releaseGroup') {
return (
<TableRowCell key={name} className={styles.releaseGroup}>
{'releaseGroup' in data ? data.releaseGroup : ''}
</TableRowCell>
);
}
if (name === 'sourceTitle') {
return <TableRowCell key={name}>{sourceTitle}</TableRowCell>;
}
if (name === 'details') {
return (
<TableRowCell key={name} className={styles.details}>
<IconButton name={icons.INFO} onPress={handleDetailsPress} />
</TableRowCell>
);
}
return null;
})}
<HistoryDetailsModal
isOpen={isDetailsModalOpen}
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
onMarkAsFailedPress={handleMarkAsFailedPress}
onModalClose={handleDetailsModalClose}
/>
</TableRow>
);
}
export default HistoryRow;

View File

@@ -1,76 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import HistoryRow from './HistoryRow';
function createMapStateToProps() {
return createSelector(
createSeriesSelector(),
createEpisodeSelector(),
createUISettingsSelector(),
(series, episode, uiSettings) => {
return {
series,
episode,
shortDateFormat: uiSettings.shortDateFormat,
timeFormat: uiSettings.timeFormat
};
}
);
}
const mapDispatchToProps = {
fetchHistory,
markAsFailed
};
class HistoryRowConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
if (
prevProps.isMarkingAsFailed &&
!this.props.isMarkingAsFailed &&
!this.props.markAsFailedError
) {
this.props.fetchHistory();
}
}
//
// Listeners
onMarkAsFailedPress = () => {
this.props.markAsFailed({ id: this.props.id });
};
//
// Render
render() {
return (
<HistoryRow
{...this.props}
onMarkAsFailedPress={this.onMarkAsFailedPress}
/>
);
}
}
HistoryRowConnector.propTypes = {
id: PropTypes.number.isRequired,
isMarkingAsFailed: PropTypes.bool,
markAsFailedError: PropTypes.object,
fetchHistory: PropTypes.func.isRequired,
markAsFailed: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector);

View File

@@ -11,3 +11,7 @@
border-color: var(--usenetColor);
background-color: var(--usenetColor);
}
.unknown {
composes: label from '~Components/Label.css';
}

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'torrent': string;
'unknown': string;
'usenet': string;
}
export const cssExports: CssExports;

View File

@@ -1,20 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import styles from './ProtocolLabel.css';
function ProtocolLabel({ protocol }) {
const protocolName = protocol === 'usenet' ? 'nzb' : protocol;
return (
<Label className={styles[protocol]}>
{protocolName}
</Label>
);
}
ProtocolLabel.propTypes = {
protocol: PropTypes.string.isRequired
};
export default ProtocolLabel;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import Label from 'Components/Label';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import styles from './ProtocolLabel.css';
interface ProtocolLabelProps {
protocol: DownloadProtocol;
}
function ProtocolLabel({ protocol }: ProtocolLabelProps) {
const protocolName = protocol === 'usenet' ? 'nzb' : protocol;
return <Label className={styles[protocol]}>{protocolName}</Label>;
}
export default ProtocolLabel;

View File

@@ -1,372 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import 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 { align, icons, kinds } from 'Helpers/Props';
import getRemovedItems from 'Utilities/Object/getRemovedItems';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import QueueFilterModal from './QueueFilterModal';
import QueueOptionsConnector from './QueueOptionsConnector';
import QueueRowConnector from './QueueRowConnector';
import RemoveQueueItemModal from './RemoveQueueItemModal';
class Queue extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._shouldBlockRefresh = false;
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
isPendingSelected: false,
isConfirmRemoveModalOpen: false,
items: props.items
};
}
shouldComponentUpdate() {
if (this._shouldBlockRefresh) {
return false;
}
return true;
}
componentDidUpdate(prevProps) {
const {
items,
isEpisodesFetching
} = this.props;
if (
(!isEpisodesFetching && prevProps.isEpisodesFetching) ||
(hasDifferentItems(prevProps.items, items) && !items.some((e) => e.episodeId))
) {
this.setState((state) => {
return {
...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)),
items
};
});
return;
}
const nextState = {};
if (prevProps.items !== items) {
nextState.items = items;
}
const selectedIds = this.getSelectedIds();
const isPendingSelected = _.some(this.props.items, (item) => {
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
});
if (isPendingSelected !== this.state.isPendingSelected) {
nextState.isPendingSelected = isPendingSelected;
}
if (!_.isEmpty(nextState)) {
this.setState(nextState);
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
onQueueRowModalOpenOrClose = (isOpen) => {
this._shouldBlockRefresh = isOpen;
};
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
};
onGrabSelectedPress = () => {
this.props.onGrabSelectedPress(this.getSelectedIds());
};
onRemoveSelectedPress = () => {
this.setState({ isConfirmRemoveModalOpen: true }, () => {
this._shouldBlockRefresh = true;
});
};
onRemoveSelectedConfirmed = (payload) => {
this._shouldBlockRefresh = false;
this.props.onRemoveSelectedPress({ ids: this.getSelectedIds(), ...payload });
this.setState({ isConfirmRemoveModalOpen: false });
};
onConfirmRemoveModalClose = () => {
this._shouldBlockRefresh = false;
this.setState({ isConfirmRemoveModalOpen: false });
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
isEpisodesFetching,
isEpisodesPopulated,
episodesError,
columns,
selectedFilterKey,
filters,
customFilters,
count,
totalRecords,
isGrabbing,
isRemoving,
isRefreshMonitoredDownloadsExecuting,
onRefreshPress,
onFilterSelect,
...otherProps
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
isConfirmRemoveModalOpen,
isPendingSelected,
items
} = this.state;
const isRefreshing = isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
const hasError = error || episodesError;
const selectedIds = this.getSelectedIds();
const selectedCount = selectedIds.length;
const disableSelectedActions = selectedCount === 0;
return (
<PageContent title={translate('Queue')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh"
iconName={icons.REFRESH}
isSpinning={isRefreshing}
onPress={onRefreshPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('GrabSelected')}
iconName={icons.DOWNLOAD}
isDisabled={disableSelectedActions || !isPendingSelected}
isSpinning={isGrabbing}
onPress={this.onGrabSelectedPress}
/>
<PageToolbarButton
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={disableSelectedActions}
isSpinning={isRemoving}
onPress={this.onRemoveSelectedPress}
/>
</PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
>
<TableOptionsModalWrapper
columns={columns}
maxPageSize={200}
{...otherProps}
optionsComponent={QueueOptionsConnector}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={QueueFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isRefreshing && !isAllPopulated ?
<LoadingIndicator /> :
null
}
{
!isRefreshing && hasError ?
<Alert kind={kinds.DANGER}>
{translate('QueueLoadError')}
</Alert> :
null
}
{
isAllPopulated && !hasError && !items.length ?
<Alert kind={kinds.INFO}>
{
selectedFilterKey !== 'all' && count > 0 ?
translate('QueueFilterHasNoItems') :
translate('QueueIsEmpty')
}
</Alert> :
null
}
{
isAllPopulated && !hasError && !!items.length ?
<div>
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
optionsComponent={QueueOptionsConnector}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<QueueRowConnector
key={item.id}
episodeId={item.episodeId}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
onQueueRowModalOpenOrClose={this.onQueueRowModalOpenOrClose}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isRefreshing}
{...otherProps}
/>
</div> :
null
}
</PageContentBody>
<RemoveQueueItemModal
isOpen={isConfirmRemoveModalOpen}
selectedCount={selectedCount}
canChangeCategory={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory);
})
)}
canIgnore={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
return !!(item && item.seriesId && item.episodeId);
})
)}
pending={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
if (!item) {
return false;
}
return item.status === 'delay' || item.status === 'downloadClientUnavailable';
})
)}
onRemovePress={this.onRemoveSelectedConfirmed}
onModalClose={this.onConfirmRemoveModalClose}
/>
</PageContent>
);
}
}
Queue.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isEpisodesFetching: PropTypes.bool.isRequired,
isEpisodesPopulated: PropTypes.bool.isRequired,
episodesError: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
count: PropTypes.number.isRequired,
totalRecords: PropTypes.number,
isGrabbing: PropTypes.bool.isRequired,
isRemoving: PropTypes.bool.isRequired,
isRefreshMonitoredDownloadsExecuting: PropTypes.bool.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onGrabSelectedPress: PropTypes.func.isRequired,
onRemoveSelectedPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
Queue.defaultProps = {
count: 0
};
export default Queue;

View File

@@ -0,0 +1,415 @@
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import {
clearQueue,
fetchQueue,
gotoQueuePage,
grabQueueItems,
removeQueueItems,
setQueueFilter,
setQueueSort,
setQueueTableOption,
} from 'Store/Actions/queueActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import QueueItem from 'typings/Queue';
import { TableOptionsChangePayload } from 'typings/Table';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import {
registerPagePopulator,
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 QueueRow from './QueueRow';
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
import createQueueStatusSelector from './Status/createQueueStatusSelector';
function Queue() {
const requestCurrentPage = useCurrentPage();
const dispatch = useDispatch();
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
isGrabbing,
isRemoving,
} = useSelector((state: AppState) => state.queue.paged);
const { count } = useSelector(createQueueStatusSelector());
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('queue'));
const isRefreshMonitoredDownloadsExecuting = useSelector(
createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS)
);
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 isPendingSelected = useMemo(() => {
return items.some((item) => {
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
});
}, [items, selectedIds]);
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
useState(false);
const isRefreshing =
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
const isAllPopulated =
isPopulated &&
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
const hasError = error || episodesError;
const selectedCount = selectedIds.length;
const disableSelectedActions = selectedCount === 0;
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const handleRefreshPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.REFRESH_MONITORED_DOWNLOADS,
})
);
}, [dispatch]);
const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => {
shouldBlockRefresh.current = isOpen;
}, []);
const handleGrabSelectedPress = useCallback(() => {
dispatch(grabQueueItems({ ids: selectedIds }));
}, [selectedIds, dispatch]);
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 handleConfirmRemoveModalClose = useCallback(() => {
shouldBlockRefresh.current = false;
setIsConfirmRemoveModalOpen(false);
}, [setIsConfirmRemoveModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoQueuePage,
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
dispatch(setQueueFilter({ selectedFilterKey }));
},
[dispatch]
);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setQueueSort({ sortKey }));
},
[dispatch]
);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setQueueTableOption(payload));
if (payload.pageSize) {
dispatch(gotoQueuePage({ page: 1 }));
}
},
[dispatch]
);
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());
};
registerPagePopulator(repopulate);
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
if (!shouldBlockRefresh.current) {
currentQueue.current = (
<PageContentBody>
{isRefreshing && !isAllPopulated ? <LoadingIndicator /> : null}
{!isRefreshing && hasError ? (
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
) : null}
{isAllPopulated && !hasError && !items.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey !== 'all' && count > 0
? translate('QueueFilterHasNoItems')
: translate('QueueIsEmpty')}
</Alert>
) : null}
{isAllPopulated && !hasError && !!items.length ? (
<div>
<Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
return (
<QueueRow
key={item.id}
episodeId={item.episodeId}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={handleSelectedChange}
onQueueRowModalOpenOrClose={
handleQueueRowModalOpenOrClose
}
/>
);
})}
</TableBody>
</Table>
<TablePager
page={page}
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
</div>
) : null}
</PageContentBody>
);
}
return (
<PageContent title={translate('Queue')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh"
iconName={icons.REFRESH}
isSpinning={isRefreshing}
onPress={handleRefreshPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('GrabSelected')}
iconName={icons.DOWNLOAD}
isDisabled={disableSelectedActions || !isPendingSelected}
isSpinning={isGrabbing}
onPress={handleGrabSelectedPress}
/>
<PageToolbarButton
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={disableSelectedActions}
isSpinning={isRemoving}
onPress={handleRemoveSelectedPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
maxPageSize={200}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={QueueFilterModal}
onFilterSelect={handleFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
{currentQueue.current}
<RemoveQueueItemModal
isOpen={isConfirmRemoveModalOpen}
selectedCount={selectedCount}
canChangeCategory={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory);
})
}
canIgnore={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
return !!(item && item.seriesId && item.episodeId);
})
}
isPending={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
if (!item) {
return false;
}
return (
item.status === 'delay' ||
item.status === 'downloadClientUnavailable'
);
})
}
onRemovePress={handleRemoveSelectedConfirmed}
onModalClose={handleConfirmRemoveModalClose}
/>
</PageContent>
);
}
export default Queue;

View File

@@ -1,203 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import * as queueActions from 'Store/Actions/queueActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
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 Queue from './Queue';
function createMapStateToProps() {
return createSelector(
(state) => state.episodes,
(state) => state.queue.options,
(state) => state.queue.paged,
(state) => state.queue.status.item,
createCustomFiltersSelector('queue'),
createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS),
(episodes, options, queue, status, customFilters, isRefreshMonitoredDownloadsExecuting) => {
return {
count: options.includeUnknownSeriesItems ? status.totalCount : status.count,
isEpisodesFetching: episodes.isFetching,
isEpisodesPopulated: episodes.isPopulated,
episodesError: episodes.error,
customFilters,
isRefreshMonitoredDownloadsExecuting,
...options,
...queue
};
}
);
}
const mapDispatchToProps = {
...queueActions,
fetchEpisodes,
clearEpisodes,
executeCommand
};
class QueueConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchQueue,
fetchQueueStatus,
gotoQueueFirstPage
} = this.props;
registerPagePopulator(this.repopulate);
if (useCurrentPage) {
fetchQueue();
} else {
gotoQueueFirstPage();
}
fetchQueueStatus();
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const episodeIds = selectUniqueIds(this.props.items, 'episodeId');
if (episodeIds.length) {
this.props.fetchEpisodes({ episodeIds });
} else {
this.props.clearEpisodes();
}
}
if (
this.props.includeUnknownSeriesItems !==
prevProps.includeUnknownSeriesItems
) {
this.repopulate();
}
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
this.props.clearQueue();
this.props.clearEpisodes();
}
//
// Control
repopulate = () => {
this.props.fetchQueue();
};
//
// Listeners
onFirstPagePress = () => {
this.props.gotoQueueFirstPage();
};
onPreviousPagePress = () => {
this.props.gotoQueuePreviousPage();
};
onNextPagePress = () => {
this.props.gotoQueueNextPage();
};
onLastPagePress = () => {
this.props.gotoQueueLastPage();
};
onPageSelect = (page) => {
this.props.gotoQueuePage({ page });
};
onSortPress = (sortKey) => {
this.props.setQueueSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setQueueFilter({ selectedFilterKey });
};
onTableOptionChange = (payload) => {
this.props.setQueueTableOption(payload);
if (payload.pageSize) {
this.props.gotoQueueFirstPage();
}
};
onRefreshPress = () => {
this.props.executeCommand({
name: commandNames.REFRESH_MONITORED_DOWNLOADS
});
};
onGrabSelectedPress = (ids) => {
this.props.grabQueueItems({ ids });
};
onRemoveSelectedPress = (payload) => {
this.props.removeQueueItems(payload);
};
//
// Render
render() {
return (
<Queue
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onRefreshPress={this.onRefreshPress}
onGrabSelectedPress={this.onGrabSelectedPress}
onRemoveSelectedPress={this.onRemoveSelectedPress}
{...this.props}
/>
);
}
}
QueueConnector.propTypes = {
includeUnknownSeriesItems: PropTypes.bool.isRequired,
useCurrentPage: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchQueue: PropTypes.func.isRequired,
fetchQueueStatus: PropTypes.func.isRequired,
gotoQueueFirstPage: PropTypes.func.isRequired,
gotoQueuePreviousPage: PropTypes.func.isRequired,
gotoQueueNextPage: PropTypes.func.isRequired,
gotoQueueLastPage: PropTypes.func.isRequired,
gotoQueuePage: PropTypes.func.isRequired,
setQueueSort: PropTypes.func.isRequired,
setQueueFilter: PropTypes.func.isRequired,
setQueueTableOption: PropTypes.func.isRequired,
clearQueue: PropTypes.func.isRequired,
grabQueueItems: PropTypes.func.isRequired,
removeQueueItems: PropTypes.func.isRequired,
fetchEpisodes: PropTypes.func.isRequired,
clearEpisodes: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(QueueConnector)
);

View File

@@ -1,36 +1,49 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, tooltipPositions } from 'Helpers/Props';
import {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,
StatusMessage,
} from 'typings/Queue';
import translate from 'Utilities/String/translate';
import QueueStatus from './QueueStatus';
import styles from './QueueDetails.css';
function QueueDetails(props) {
interface QueueDetailsProps {
title: string;
size: number;
sizeleft: number;
estimatedCompletionTime?: string;
status: string;
trackedDownloadState?: QueueTrackedDownloadState;
trackedDownloadStatus?: QueueTrackedDownloadStatus;
statusMessages?: StatusMessage[];
errorMessage?: string;
progressBar: React.ReactNode;
}
function QueueDetails(props: QueueDetailsProps) {
const {
title,
size,
sizeleft,
status,
trackedDownloadState,
trackedDownloadStatus,
trackedDownloadState = 'downloading',
trackedDownloadStatus = 'ok',
statusMessages,
errorMessage,
progressBar
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';
const hasError = trackedDownloadStatus === 'error';
if (
(isDownloading || isPaused) &&
!hasWarning &&
!hasError
) {
if ((isDownloading || isPaused) && !hasWarning && !hasError) {
const state = isPaused ? translate('Paused') : translate('Downloading');
if (progress < 5) {
@@ -45,11 +58,9 @@ function QueueDetails(props) {
return (
<Popover
className={styles.progressBarContainer}
anchor={progressBar}
anchor={progressBar!}
title={`${state} - ${progress.toFixed(1)}%`}
body={
<div>{title}</div>
}
body={<div>{title}</div>}
position={tooltipPositions.LEFT}
/>
);
@@ -68,22 +79,4 @@ function QueueDetails(props) {
);
}
QueueDetails.propTypes = {
title: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
sizeleft: PropTypes.number.isRequired,
estimatedCompletionTime: PropTypes.string,
status: PropTypes.string.isRequired,
trackedDownloadState: PropTypes.string.isRequired,
trackedDownloadStatus: PropTypes.string.isRequired,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string,
progressBar: PropTypes.node.isRequired
};
QueueDetails.defaultProps = {
trackedDownloadStatus: 'ok',
trackedDownloadState: 'downloading'
};
export default QueueDetails;

View File

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setQueueFilter } from 'Store/Actions/queueActions';
function createQueueSelector() {
@@ -23,9 +23,7 @@ function createFilterBuilderPropsSelector() {
);
}
interface QueueFilterModalProps {
isOpen: boolean;
}
type QueueFilterModalProps = FilterModalProps<History>;
export default function QueueFilterModal(props: QueueFilterModalProps) {
const sectionItems = useSelector(createQueueSelector());
@@ -43,7 +41,6 @@ export default function QueueFilterModal(props: QueueFilterModalProps) {
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}

View File

@@ -1,78 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
class QueueOptions extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
includeUnknownSeriesItems: props.includeUnknownSeriesItems
};
}
componentDidUpdate(prevProps) {
const {
includeUnknownSeriesItems
} = this.props;
if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) {
this.setState({
includeUnknownSeriesItems
});
}
}
//
// Listeners
onOptionChange = ({ name, value }) => {
this.setState({
[name]: value
}, () => {
this.props.onOptionChange({
[name]: value
});
});
};
//
// Render
render() {
const {
includeUnknownSeriesItems
} = this.state;
return (
<Fragment>
<FormGroup>
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')}
onChange={this.onOptionChange}
/>
</FormGroup>
</Fragment>
);
}
}
QueueOptions.propTypes = {
includeUnknownSeriesItems: PropTypes.bool.isRequired,
onOptionChange: PropTypes.func.isRequired
};
export default QueueOptions;

View File

@@ -0,0 +1,48 @@
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,19 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setQueueOption } from 'Store/Actions/queueActions';
import QueueOptions from './QueueOptions';
function createMapStateToProps() {
return createSelector(
(state) => state.queue.options,
(options) => {
return options;
}
);
}
const mapDispatchToProps = {
onOptionChange: setQueueOption
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions);

View File

@@ -26,4 +26,5 @@
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 70px;
text-align: right;
}

View File

@@ -1,481 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import Tooltip from 'Components/Tooltip/Tooltip';
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 { icons, kinds, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import QueueStatusCell from './QueueStatusCell';
import RemoveQueueItemModal from './RemoveQueueItemModal';
import TimeleftCell from './TimeleftCell';
import styles from './QueueRow.css';
class QueueRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isRemoveQueueItemModalOpen: false,
isInteractiveImportModalOpen: false
};
}
//
// Listeners
onRemoveQueueItemPress = () => {
this.setState({ isRemoveQueueItemModalOpen: true });
};
onRemoveQueueItemModalConfirmed = (blocklist, skipRedownload) => {
const {
onRemoveQueueItemPress,
onQueueRowModalOpenOrClose
} = this.props;
onQueueRowModalOpenOrClose(false);
onRemoveQueueItemPress(blocklist, skipRedownload);
this.setState({ isRemoveQueueItemModalOpen: false });
};
onRemoveQueueItemModalClose = () => {
this.props.onQueueRowModalOpenOrClose(false);
this.setState({ isRemoveQueueItemModalOpen: false });
};
onInteractiveImportPress = () => {
this.props.onQueueRowModalOpenOrClose(true);
this.setState({ isInteractiveImportModalOpen: true });
};
onInteractiveImportModalClose = () => {
this.props.onQueueRowModalOpenOrClose(false);
this.setState({ isInteractiveImportModalOpen: false });
};
//
// Render
render() {
const {
id,
downloadId,
title,
status,
trackedDownloadStatus,
trackedDownloadState,
statusMessages,
errorMessage,
series,
episode,
languages,
quality,
customFormats,
customFormatScore,
protocol,
indexer,
outputPath,
downloadClient,
downloadClientHasPostImportCategory,
estimatedCompletionTime,
added,
timeleft,
size,
sizeleft,
showRelativeDates,
shortDateFormat,
timeFormat,
isGrabbing,
grabError,
isRemoving,
isSelected,
columns,
onSelectedChange,
onGrabPress
} = this.props;
const {
isRemoveQueueItemModalOpen,
isInteractiveImportModalOpen
} = this.state;
const progress = 100 - (sizeleft / size * 100);
const showInteractiveImport = status === 'completed' && trackedDownloadStatus === 'warning';
const isPending = status === 'delay' || status === 'downloadClientUnavailable';
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<QueueStatusCell
key={name}
sourceTitle={title}
status={status}
trackedDownloadStatus={trackedDownloadStatus}
trackedDownloadState={trackedDownloadState}
statusMessages={statusMessages}
errorMessage={errorMessage}
/>
);
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
{
series ?
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/> :
title
}
</TableRowCell>
);
}
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}
/> :
'-'
}
</TableRowCell>
);
}
if (name === 'episodes.title') {
return (
<TableRowCell key={name}>
{
episode ?
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeFileId={episode.episodeFileId}
episodeTitle={episode.title}
showOpenSeriesButton={true}
/> :
'-'
}
</TableRowCell>
);
}
if (name === 'episodes.airDateUtc') {
if (episode) {
return (
<RelativeDateCellConnector
key={name}
date={episode.airDateUtc}
/>
);
}
return (
<TableRowCell key={name}>
-
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell key={name}>
<EpisodeLanguages
languages={languages}
/>
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
{
quality ?
<EpisodeQuality
quality={quality}
/> :
null
}
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'protocol') {
return (
<TableRowCell key={name}>
<ProtocolLabel
protocol={protocol}
/>
</TableRowCell>
);
}
if (name === 'indexer') {
return (
<TableRowCell key={name}>
{indexer}
</TableRowCell>
);
}
if (name === 'downloadClient') {
return (
<TableRowCell key={name}>
{downloadClient}
</TableRowCell>
);
}
if (name === 'title') {
return (
<TableRowCell key={name}>
{title}
</TableRowCell>
);
}
if (name === 'size') {
return (
<TableRowCell key={name}>{formatBytes(size)}</TableRowCell>
);
}
if (name === 'outputPath') {
return (
<TableRowCell key={name}>
{outputPath}
</TableRowCell>
);
}
if (name === 'estimatedCompletionTime') {
return (
<TimeleftCell
key={name}
status={status}
estimatedCompletionTime={estimatedCompletionTime}
timeleft={timeleft}
size={size}
sizeleft={sizeleft}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
);
}
if (name === 'progress') {
return (
<TableRowCell
key={name}
className={styles.progress}
>
{
!!progress &&
<ProgressBar
progress={progress}
title={`${progress.toFixed(1)}%`}
/>
}
</TableRowCell>
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
date={added}
/>
);
}
if (name === 'actions') {
return (
<TableRowCell
key={name}
className={styles.actions}
>
{
showInteractiveImport &&
<IconButton
name={icons.INTERACTIVE}
onPress={this.onInteractiveImportPress}
/>
}
{
isPending &&
<SpinnerIconButton
name={icons.DOWNLOAD}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
isSpinning={isGrabbing}
onPress={onGrabPress}
/>
}
<SpinnerIconButton
title={translate('RemoveFromQueue')}
name={icons.REMOVE}
isSpinning={isRemoving}
onPress={this.onRemoveQueueItemPress}
/>
</TableRowCell>
);
}
return null;
})
}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
downloadId={downloadId}
title={title}
onModalClose={this.onInteractiveImportModalClose}
/>
<RemoveQueueItemModal
isOpen={isRemoveQueueItemModalOpen}
sourceTitle={title}
canChangeCategory={!!downloadClientHasPostImportCategory}
canIgnore={!!series}
isPending={isPending}
onRemovePress={this.onRemoveQueueItemModalConfirmed}
onModalClose={this.onRemoveQueueItemModalClose}
/>
</TableRow>
);
}
}
QueueRow.propTypes = {
id: PropTypes.number.isRequired,
downloadId: PropTypes.string,
title: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
trackedDownloadStatus: PropTypes.string,
trackedDownloadState: PropTypes.string,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string,
series: PropTypes.object,
episode: PropTypes.object,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
outputPath: PropTypes.string,
downloadClient: PropTypes.string,
downloadClientHasPostImportCategory: PropTypes.bool,
estimatedCompletionTime: PropTypes.string,
added: PropTypes.string,
timeleft: PropTypes.string,
size: PropTypes.number,
sizeleft: PropTypes.number,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
isGrabbing: PropTypes.bool.isRequired,
grabError: PropTypes.object,
isRemoving: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelectedChange: PropTypes.func.isRequired,
onGrabPress: PropTypes.func.isRequired,
onRemoveQueueItemPress: PropTypes.func.isRequired,
onQueueRowModalOpenOrClose: PropTypes.func.isRequired
};
QueueRow.defaultProps = {
customFormats: [],
isGrabbing: false,
isRemoving: false
};
export default QueueRow;

View File

@@ -0,0 +1,411 @@
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 IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import Tooltip from 'Components/Tooltip/Tooltip';
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 { icons, kinds, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CustomFormat from 'typings/CustomFormat';
import { SelectStateInputProps } from 'typings/props';
import {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,
StatusMessage,
} from 'typings/Queue';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import QueueStatusCell from './QueueStatusCell';
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
import TimeleftCell from './TimeleftCell';
import styles from './QueueRow.css';
interface QueueRowProps {
id: number;
seriesId?: number;
episodeId?: number;
downloadId?: string;
title: string;
status: string;
trackedDownloadStatus?: QueueTrackedDownloadStatus;
trackedDownloadState?: QueueTrackedDownloadState;
statusMessages?: StatusMessage[];
errorMessage?: string;
languages: Language[];
quality: QualityModel;
customFormats?: CustomFormat[];
customFormatScore: number;
protocol: DownloadProtocol;
indexer?: string;
outputPath?: string;
downloadClient?: string;
downloadClientHasPostImportCategory?: boolean;
estimatedCompletionTime?: string;
added?: string;
timeleft?: string;
size: number;
sizeleft: number;
isGrabbing?: boolean;
grabError?: Error;
isRemoving?: boolean;
isSelected?: boolean;
columns: Column[];
onSelectedChange: (options: SelectStateInputProps) => void;
onQueueRowModalOpenOrClose: (isOpen: boolean) => void;
}
function QueueRow(props: QueueRowProps) {
const {
id,
seriesId,
episodeId,
downloadId,
title,
status,
trackedDownloadStatus,
trackedDownloadState,
statusMessages,
errorMessage,
languages,
quality,
customFormats = [],
customFormatScore,
protocol,
indexer,
outputPath,
downloadClient,
downloadClientHasPostImportCategory,
estimatedCompletionTime,
added,
timeleft,
size,
sizeleft,
isGrabbing = false,
grabError,
isRemoving = false,
isSelected,
columns,
onSelectedChange,
onQueueRowModalOpenOrClose,
} = props;
const dispatch = useDispatch();
const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes');
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
useState(false);
const [isInteractiveImportModalOpen, setIsInteractiveImportModalOpen] =
useState(false);
const handleGrabPress = useCallback(() => {
dispatch(grabQueueItem({ id }));
}, [id, dispatch]);
const handleInteractiveImportPress = useCallback(() => {
onQueueRowModalOpenOrClose(true);
setIsInteractiveImportModalOpen(true);
}, [setIsInteractiveImportModalOpen, onQueueRowModalOpenOrClose]);
const handleInteractiveImportModalClose = useCallback(() => {
onQueueRowModalOpenOrClose(false);
setIsInteractiveImportModalOpen(false);
}, [setIsInteractiveImportModalOpen, onQueueRowModalOpenOrClose]);
const handleRemoveQueueItemPress = useCallback(() => {
onQueueRowModalOpenOrClose(true);
setIsRemoveQueueItemModalOpen(true);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const handleRemoveQueueItemModalConfirmed = useCallback(
(payload: RemovePressProps) => {
onQueueRowModalOpenOrClose(false);
dispatch(removeQueueItem({ id, ...payload }));
setIsRemoveQueueItemModalOpen(false);
},
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch]
);
const handleRemoveQueueItemModalClose = useCallback(() => {
onQueueRowModalOpenOrClose(false);
setIsRemoveQueueItemModalOpen(false);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const progress = 100 - (sizeleft / size) * 100;
const showInteractiveImport =
status === 'completed' && trackedDownloadStatus === 'warning';
const isPending =
status === 'delay' || status === 'downloadClientUnavailable';
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<QueueStatusCell
key={name}
sourceTitle={title}
status={status}
trackedDownloadStatus={trackedDownloadStatus}
trackedDownloadState={trackedDownloadState}
statusMessages={statusMessages}
errorMessage={errorMessage}
/>
);
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
{series ? (
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
) : (
title
)}
</TableRowCell>
);
}
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}
/>
) : (
'-'
)}
</TableRowCell>
);
}
if (name === 'episodes.title') {
return (
<TableRowCell key={name}>
{series && episode ? (
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
) : (
'-'
)}
</TableRowCell>
);
}
if (name === 'episodes.airDateUtc') {
if (episode) {
return <RelativeDateCell key={name} date={episode.airDateUtc} />;
}
return <TableRowCell key={name}>-</TableRowCell>;
}
if (name === 'languages') {
return (
<TableRowCell key={name}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
{quality ? <EpisodeQuality quality={quality} /> : null}
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell key={name} className={styles.customFormatScore}>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'protocol') {
return (
<TableRowCell key={name}>
<ProtocolLabel protocol={protocol} />
</TableRowCell>
);
}
if (name === 'indexer') {
return <TableRowCell key={name}>{indexer}</TableRowCell>;
}
if (name === 'downloadClient') {
return <TableRowCell key={name}>{downloadClient}</TableRowCell>;
}
if (name === 'title') {
return <TableRowCell key={name}>{title}</TableRowCell>;
}
if (name === 'size') {
return <TableRowCell key={name}>{formatBytes(size)}</TableRowCell>;
}
if (name === 'outputPath') {
return <TableRowCell key={name}>{outputPath}</TableRowCell>;
}
if (name === 'estimatedCompletionTime') {
return (
<TimeleftCell
key={name}
status={status}
estimatedCompletionTime={estimatedCompletionTime}
timeleft={timeleft}
size={size}
sizeleft={sizeleft}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
);
}
if (name === 'progress') {
return (
<TableRowCell key={name} className={styles.progress}>
{!!progress && (
<ProgressBar
progress={progress}
title={`${progress.toFixed(1)}%`}
/>
)}
</TableRowCell>
);
}
if (name === 'added') {
return <RelativeDateCell key={name} date={added} />;
}
if (name === 'actions') {
return (
<TableRowCell key={name} className={styles.actions}>
{showInteractiveImport ? (
<IconButton
name={icons.INTERACTIVE}
onPress={handleInteractiveImportPress}
/>
) : null}
{isPending ? (
<SpinnerIconButton
name={icons.DOWNLOAD}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
isSpinning={isGrabbing}
onPress={handleGrabPress}
/>
) : null}
<SpinnerIconButton
title={translate('RemoveFromQueue')}
name={icons.REMOVE}
isSpinning={isRemoving}
onPress={handleRemoveQueueItemPress}
/>
</TableRowCell>
);
}
return null;
})}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
downloadId={downloadId}
modalTitle={title}
onModalClose={handleInteractiveImportModalClose}
/>
<RemoveQueueItemModal
isOpen={isRemoveQueueItemModalOpen}
sourceTitle={title}
canChangeCategory={!!downloadClientHasPostImportCategory}
canIgnore={!!series}
isPending={isPending}
onRemovePress={handleRemoveQueueItemModalConfirmed}
onModalClose={handleRemoveQueueItemModalClose}
/>
</TableRow>
);
}
export default QueueRow;

View File

@@ -1,70 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import QueueRow from './QueueRow';
function createMapStateToProps() {
return createSelector(
createSeriesSelector(),
createEpisodeSelector(),
createUISettingsSelector(),
(series, episode, uiSettings) => {
const result = {
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
timeFormat: uiSettings.timeFormat
};
result.series = series;
result.episode = episode;
return result;
}
);
}
const mapDispatchToProps = {
grabQueueItem,
removeQueueItem
};
class QueueRowConnector extends Component {
//
// Listeners
onGrabPress = () => {
this.props.grabQueueItem({ id: this.props.id });
};
onRemoveQueueItemPress = (payload) => {
this.props.removeQueueItem({ id: this.props.id, ...payload });
};
//
// Render
render() {
return (
<QueueRow
{...this.props}
onGrabPress={this.onGrabPress}
onRemoveQueueItemPress={this.onRemoveQueueItemPress}
/>
);
}
}
QueueRowConnector.propTypes = {
id: PropTypes.number.isRequired,
episode: PropTypes.object,
grabQueueItem: PropTypes.func.isRequired,
removeQueueItem: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector);

View File

@@ -1,51 +1,59 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Icon, { IconKind } from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import { icons, kinds } from 'Helpers/Props';
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
import {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,
StatusMessage,
} from 'typings/Queue';
import translate from 'Utilities/String/translate';
import styles from './QueueStatus.css';
function getDetailedPopoverBody(statusMessages) {
function getDetailedPopoverBody(statusMessages: StatusMessage[]) {
return (
<div>
{
statusMessages.map(({ title, messages }) => {
return (
<div
key={title}
className={messages.length ? undefined: styles.noMessages}
>
{title}
<ul>
{
messages.map((message) => {
return (
<li key={message}>
{message}
</li>
);
})
}
</ul>
</div>
);
})
}
{statusMessages.map(({ title, messages }) => {
return (
<div
key={title}
className={messages.length ? undefined : styles.noMessages}
>
{title}
<ul>
{messages.map((message) => {
return <li key={message}>{message}</li>;
})}
</ul>
</div>
);
})}
</div>
);
}
function QueueStatus(props) {
interface QueueStatusProps {
sourceTitle: string;
status: string;
trackedDownloadStatus?: QueueTrackedDownloadStatus;
trackedDownloadState?: QueueTrackedDownloadState;
statusMessages?: StatusMessage[];
errorMessage?: string;
position: TooltipPosition;
canFlip?: boolean;
}
function QueueStatus(props: QueueStatusProps) {
const {
sourceTitle,
status,
trackedDownloadStatus,
trackedDownloadState,
statusMessages,
trackedDownloadStatus = 'ok',
trackedDownloadState = 'downloading',
statusMessages = [],
errorMessage,
position,
canFlip
canFlip = false,
} = props;
const hasWarning = trackedDownloadStatus === 'warning';
@@ -53,7 +61,7 @@ function QueueStatus(props) {
// status === 'downloading'
let iconName = icons.DOWNLOADING;
let iconKind = kinds.DEFAULT;
let iconKind: IconKind = kinds.DEFAULT;
let title = translate('Downloading');
if (status === 'paused') {
@@ -115,7 +123,8 @@ function QueueStatus(props) {
if (status === 'warning') {
iconName = icons.DOWNLOADING;
iconKind = kinds.WARNING;
const warningMessage = errorMessage || translate('CheckDownloadClientForDetails');
const warningMessage =
errorMessage || translate('CheckDownloadClientForDetails');
title = translate('DownloadWarning', { warningMessage });
}
@@ -133,35 +142,17 @@ function QueueStatus(props) {
return (
<Popover
anchor={
<Icon
name={iconName}
kind={iconKind}
/>
}
anchor={<Icon name={iconName} kind={iconKind} />}
title={title}
body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle}
body={
hasWarning || hasError
? getDetailedPopoverBody(statusMessages)
: sourceTitle
}
position={position}
canFlip={canFlip}
/>
);
}
QueueStatus.propTypes = {
sourceTitle: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
trackedDownloadStatus: PropTypes.string.isRequired,
trackedDownloadState: PropTypes.string.isRequired,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string,
position: PropTypes.oneOf(tooltipPositions.all).isRequired,
canFlip: PropTypes.bool.isRequired
};
QueueStatus.defaultProps = {
trackedDownloadStatus: 'ok',
trackedDownloadState: 'downloading',
canFlip: false
};
export default QueueStatus;

View File

@@ -1,47 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { tooltipPositions } from 'Helpers/Props';
import QueueStatus from './QueueStatus';
import styles from './QueueStatusCell.css';
function QueueStatusCell(props) {
const {
sourceTitle,
status,
trackedDownloadStatus,
trackedDownloadState,
statusMessages,
errorMessage
} = props;
return (
<TableRowCell className={styles.status}>
<QueueStatus
sourceTitle={sourceTitle}
status={status}
trackedDownloadStatus={trackedDownloadStatus}
trackedDownloadState={trackedDownloadState}
statusMessages={statusMessages}
errorMessage={errorMessage}
position={tooltipPositions.RIGHT}
/>
</TableRowCell>
);
}
QueueStatusCell.propTypes = {
sourceTitle: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
trackedDownloadStatus: PropTypes.string.isRequired,
trackedDownloadState: PropTypes.string.isRequired,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string
};
QueueStatusCell.defaultProps = {
trackedDownloadStatus: 'ok',
trackedDownloadState: 'downloading'
};
export default QueueStatusCell;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,
StatusMessage,
} from 'typings/Queue';
import QueueStatus from './QueueStatus';
import styles from './QueueStatusCell.css';
interface QueueStatusCellProps {
sourceTitle: string;
status: string;
trackedDownloadStatus?: QueueTrackedDownloadStatus;
trackedDownloadState?: QueueTrackedDownloadState;
statusMessages?: StatusMessage[];
errorMessage?: string;
}
function QueueStatusCell(props: QueueStatusCellProps) {
const {
sourceTitle,
status,
trackedDownloadStatus = 'ok',
trackedDownloadState = 'downloading',
statusMessages,
errorMessage,
} = props;
return (
<TableRowCell className={styles.status}>
<QueueStatus
sourceTitle={sourceTitle}
status={status}
trackedDownloadStatus={trackedDownloadStatus}
trackedDownloadState={trackedDownloadState}
statusMessages={statusMessages}
errorMessage={errorMessage}
position="right"
/>
</TableRowCell>
);
}
export default QueueStatusCell;

View File

@@ -12,7 +12,7 @@ import { inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './RemoveQueueItemModal.css';
interface RemovePressProps {
export interface RemovePressProps {
remove: boolean;
changeCategory: boolean;
blocklist: boolean;
@@ -21,7 +21,7 @@ interface RemovePressProps {
interface RemoveQueueItemModalProps {
isOpen: boolean;
sourceTitle: string;
sourceTitle?: string;
canChangeCategory: boolean;
canIgnore: boolean;
isPending: boolean;
@@ -39,7 +39,7 @@ type BlocklistMethod =
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
const {
isOpen,
sourceTitle,
sourceTitle = '',
canIgnore,
canChangeCategory,
isPending,
@@ -118,6 +118,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
{
key: 'blocklistAndSearch',
value: translate('BlocklistAndSearch'),
isDisabled: isPending,
hint: multipleSelected
? translate('BlocklistAndSearchMultipleHint')
: translate('BlocklistAndSearchHint'),
@@ -130,7 +131,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
: translate('BlocklistOnlyHint'),
},
];
}, [multipleSelected]);
}, [isPending, multipleSelected]);
const handleRemovalMethodChange = useCallback(
({ value }: { value: RemovalMethod }) => {

View File

@@ -0,0 +1,37 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { fetchQueueStatus } from 'Store/Actions/queueActions';
import createQueueStatusSelector from './createQueueStatusSelector';
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]);
return (
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
);
}
export default QueueStatus;

View File

@@ -1,76 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import { fetchQueueStatus } from 'Store/Actions/queueActions';
function createMapStateToProps() {
return createSelector(
(state) => state.app,
(state) => state.queue.status,
(state) => state.queue.options.includeUnknownSeriesItems,
(app, status, includeUnknownSeriesItems) => {
const {
errors,
warnings,
unknownErrors,
unknownWarnings,
count,
totalCount
} = status.item;
return {
isConnected: app.isConnected,
isReconnecting: app.isReconnecting,
isPopulated: status.isPopulated,
...status.item,
count: includeUnknownSeriesItems ? totalCount : count,
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
warnings: includeUnknownSeriesItems ? warnings || unknownWarnings : warnings
};
}
);
}
const mapDispatchToProps = {
fetchQueueStatus
};
class QueueStatusConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.isPopulated) {
this.props.fetchQueueStatus();
}
}
componentDidUpdate(prevProps) {
if (this.props.isConnected && prevProps.isReconnecting) {
this.props.fetchQueueStatus();
}
}
//
// Render
render() {
return (
<PageSidebarStatus
{...this.props}
/>
);
}
}
QueueStatusConnector.propTypes = {
isConnected: PropTypes.bool.isRequired,
isReconnecting: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
fetchQueueStatus: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector);

View File

@@ -0,0 +1,32 @@
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

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -11,7 +10,18 @@ import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './TimeleftCell.css';
function TimeleftCell(props) {
interface TimeleftCellProps {
estimatedCompletionTime?: string;
timeleft?: string;
status: string;
size: number;
sizeleft: number;
showRelativeDates: boolean;
shortDateFormat: string;
timeFormat: string;
}
function TimeleftCell(props: TimeleftCellProps) {
const {
estimatedCompletionTime,
timeleft,
@@ -20,16 +30,18 @@ function TimeleftCell(props) {
sizeleft,
showRelativeDates,
shortDateFormat,
timeFormat
timeFormat,
} = props;
if (status === 'delay') {
const date = getRelativeDate({
date: estimatedCompletionTime,
shortDateFormat,
showRelativeDates
showRelativeDates,
});
const time = formatTime(estimatedCompletionTime, timeFormat, {
includeMinuteZero: true,
});
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return (
<TableRowCell className={styles.timeleft}>
@@ -47,9 +59,11 @@ function TimeleftCell(props) {
const date = getRelativeDate({
date: estimatedCompletionTime,
shortDateFormat,
showRelativeDates
showRelativeDates,
});
const time = formatTime(estimatedCompletionTime, timeFormat, {
includeMinuteZero: true,
});
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return (
<TableRowCell className={styles.timeleft}>
@@ -64,11 +78,7 @@ function TimeleftCell(props) {
}
if (!timeleft || status === 'completed' || status === 'failed') {
return (
<TableRowCell className={styles.timeleft}>
-
</TableRowCell>
);
return <TableRowCell className={styles.timeleft}>-</TableRowCell>;
}
const totalSize = formatBytes(size);
@@ -84,15 +94,4 @@ function TimeleftCell(props) {
);
}
TimeleftCell.propTypes = {
estimatedCompletionTime: PropTypes.string,
timeleft: PropTypes.string,
status: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
sizeleft: PropTypes.number.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default TimeleftCell;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,6 +69,16 @@
height: 55px;
}
.originalLanguageName,
.network,
.genres {
margin-left: 8px;
}
.genres {
pointer-events: all;
}
.tvdbLink {
composes: link from '~Components/Link/Link.css';

View File

@@ -3,7 +3,10 @@
interface CssExports {
'alreadyExistsIcon': string;
'content': string;
'genres': string;
'icons': string;
'network': string;
'originalLanguageName': string;
'overlay': string;
'overview': string;
'poster': string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,10 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
div {
margin-top: 10px;
&:first-child {
margin-top: 0;
}
}
}
.label {
margin-bottom: 3px;
margin-bottom: 10px;
font-weight: bold;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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