1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-22 17:04:12 -04:00

Compare commits

..

165 Commits

Author SHA1 Message Date
Taloth Saldono
6143d20462 Untested code to get a cached SeedCriteria by infohash to rtorrent handler 2021-02-04 00:16:36 +01:00
Taloth Saldono
f3f2648ce5 Fixed: Global scene mapping aliases disappeared from UI 2021-02-01 16:52:34 +01:00
Taloth Saldono
d1c3ae1749 Fixed: Validation of new qbittorrent max-ratio action config 2021-02-01 16:52:32 +01:00
Taloth Saldono
1cbcad6960 Added searchEngine support in Newznab/Torznab caps 2021-02-01 16:52:31 +01:00
Taloth Saldono
ab45910e56 Fixed: FLAC audio channels in media info 2021-02-01 16:52:30 +01:00
Mark McDowall
53f5694535 New: Disable season search if series is unmonitored 2021-01-31 11:04:55 -08:00
bakerboy448
7a3f4e8033 Fixed: Handle more obfuscated names
Closes #4198
2021-01-31 10:53:26 -08:00
Robin Dadswell
474f4bcc6d New: On Episode File Delete For Upgrade notification option 2021-01-30 14:54:26 -08:00
Robin Dadswell
ec058436d3 New: Unify series custom filter options
Closes #3548
2021-01-30 14:34:20 -08:00
Mark McDowall
39ca348666 Fixed: Label for 'On Episode File Delete' 2021-01-24 13:21:16 -08:00
Mark McDowall
02a46349a2 Consistent types for on delete custom script events 2021-01-24 12:19:15 -08:00
Mark McDowall
98dc20d919 Fixed: Webhook events not sent for series deletions 2021-01-24 12:18:39 -08:00
Mark McDowall
a510c9e86d Separate event types for series and episode deletions 2021-01-24 12:17:01 -08:00
Mark McDowall
f5d690aa7b Fixed: Queue refresh closing manual import from queue if items change
Closes #4221
2021-01-24 10:14:45 -08:00
Robin Dadswell
c91fabcf2d New: On Delete Notifications
Closes #2410
2021-01-24 01:21:29 -08:00
Mark McDowall
21fafb895f Fixed: Files with lower preferred word scores are imported
Closes #4212
2021-01-24 01:18:08 -08:00
Mark McDowall
8047e5aa67 Fixed: Series Type Filter
Closes #4274
2021-01-23 19:15:36 -08:00
Mark McDowall
546d65b663 Manual Import episode improvements
New: Show absolute episode number (for anime series) and title in manual import
New: Show absolute episode number (for anime series) in manual import episode selection
2021-01-23 19:02:22 -08:00
Mark McDowall
328cfa12f6 Fixed: Improve multi-episode title squashing 2021-01-23 18:16:32 -08:00
bakerboy448
d475fccb55 Update bug report template 2021-01-20 23:48:00 -08:00
bakerboy448
50a2e52c19 Lock closed issues after 90 days without activity 2021-01-20 23:47:19 -08:00
Jesse Chan
28e0ad38b0 New: Flood Download Client 2021-01-20 23:43:52 -08:00
Taloth Saldono
b66bf542c1 Typo for linux 2021-01-20 21:42:48 +01:00
Robin Dadswell
c05fccb90d Fixed: Error handling when cannot create folder in Recycling Bin
Closes #4163
2021-01-17 10:27:58 -08:00
Mark McDowall
ab478fd64b New: Treat Manual Bad in history as failed 2021-01-16 16:24:30 -08:00
bakerboy448
d016079f6b make HashedReleaseFixture entries generic 2021-01-16 15:48:47 -08:00
bakerboy448
4a7e5ac06e Fixed: Handle more obfuscated names
Closes #4198
2021-01-16 15:48:47 -08:00
Taloth Saldono
e10cff5414 Fixed parsing (duplicate) releases for series with multiple season number mappings 2021-01-16 21:13:32 +01:00
Taloth Saldono
6c17b4bb86 Fixed accounting for zero terminator in long path limitation
closes #4259
2021-01-16 02:49:48 +01:00
Mark McDowall
e704ee617f New: Require Encryption option for email 2021-01-15 17:29:43 -08:00
Mark McDowall
c2fcdb4457 Fixed: Managing display profiles on mobile 2021-01-15 17:29:43 -08:00
Mark McDowall
a6637b2911 Fixed: Sorting in Interactive search duplicates results
Closes #3964
2021-01-15 17:29:43 -08:00
Taloth Saldono
efb18223fe Fixed duplicate id searches due to missing Equals on SceneSeasonMapping 2021-01-16 01:32:08 +01:00
Taloth Saldono
c3e2f22adb Show separate message for unknown episode/series 2021-01-16 01:31:17 +01:00
Taloth Saldono
9de5181f01 Fixed: Regular Anime being caught in Chinese parser rules
closes #4257
2021-01-15 19:55:55 +01:00
Taloth Saldono
63607ad541 Fixed Agenda Time wrapping 2021-01-15 19:06:09 +01:00
Taloth Saldono
620359dcc6 Fixed: Updated BTN url to https
closes #4249
2021-01-14 23:30:47 +01:00
Taloth Saldono
881bbb654b Linting as usual 2021-01-14 23:20:09 +01:00
Taloth Saldono
c28cafba0a Fixed: Unnecessary certificate validation errors on localhost/loopback
closes #4215
2021-01-14 22:07:16 +01:00
Taloth Saldono
5668152d6f New: Added Scene Info to Interactive Search results to show more about the applied scene/TheXEM mappings 2021-01-14 22:07:16 +01:00
Taloth Saldono
dcda03da4a Fixed searching the wrong season. 2021-01-14 22:07:15 +01:00
Mark McDowall
ba2ca7ee29 New: Parsing of '[WEB]' as WebDL 2021-01-13 17:17:11 -08:00
bakerboy448
8077434a38 Update contributing.md 2021-01-13 17:00:09 -08:00
Qstick
a225b34806 Fix name of max NumberInput in QualityDefinition.js 2021-01-13 16:59:32 -08:00
bakerboy448
b181f8ae9f Readme updates 2021-01-13 16:59:08 -08:00
Qstick
579c443349 New: Replace SmtpClient with Mailkit
Closes #4213
2021-01-13 16:58:22 -08:00
bakerboy448
8a511a0e20 Fixed: Parse standalone UHD as 2160p if no other resolution info is present 2021-01-12 21:36:18 +01:00
Taloth Saldono
8bc0ab981c Fixed: Dailiezearch. 2021-01-11 21:04:16 +01:00
bakerboy448
741fa57f05 Update wiki link hints for health checks
Closes #4190
2021-01-09 12:39:30 -08:00
Mark McDowall
8a3027fa7c New: Allow quality size limits to be closer together 2021-01-06 22:01:50 -08:00
Mark McDowall
fddf2d1fc8 Better task interval fetching 2021-01-06 22:01:50 -08:00
Mark McDowall
6d44d6c1b7 Fixed: Only delete update folder if it exists 2021-01-06 22:01:50 -08:00
Taloth Saldono
7e045f3e3c Fixed tests 2021-01-04 22:18:08 +01:00
Taloth Saldono
f4d14301f1 No longer need the special tvdb season number handling since it's integrated into the search. 2021-01-04 21:41:05 +01:00
Taloth Saldono
17985f7a33 Fixed: Regression in searching anime by primary title 2021-01-04 20:55:13 +01:00
Taloth Saldono
772448b41b New: Support in services for multiple scene naming/numbering exceptions 2021-01-04 17:35:35 +01:00
Mark McDowall
ed2bb0d73a Fixed: Backups interval being used as minutes instead of days 2021-01-04 08:22:01 -08:00
Taloth Saldono
ae45089c62 Fixed tests 2021-01-03 21:44:46 +01:00
Taloth Saldono
f3695a41d7 Linting 2021-01-03 21:19:09 +01:00
bakerboy448
471f0016b4 Fixed: Additional handling for obfuscated releases
closes #4198
2021-01-03 21:19:03 +01:00
bakerboy448
056a699daf Fixed: Parsing of 4Kto1080p as 1080p
closes #4199
2021-01-03 21:18:48 +01:00
Mark McDowall
99be6a7e40 Use createHandleActions for adding/removing commands so itemMap is synced properly 2021-01-02 15:30:24 -08:00
Mark McDowall
c1d060ff58 New: Removing update folder from temp folder during housekeeping
Closes #4178
2021-01-02 15:13:01 -08:00
Mark McDowall
101b1ec743 New: Renamed Quick Import to Move Automatically
Close #4210
2021-01-02 12:14:16 -08:00
Mark McDowall
a4ffb256a6 Fixed UpdatePackageProviderFixture tests 2021-01-02 11:53:11 -08:00
Mark McDowall
ca52eb76ca Fixed: Don't convert series selection filter to lower case in state 2021-01-02 10:30:11 -08:00
Mark McDowall
37501094d7 Fixed: Restored robots.txt 2020-12-31 13:11:22 -08:00
Qstick
cfbb4a3235 Fixed: Timespan over 1 month shown incorrectly
Closes #4208
2020-12-31 11:51:22 -08:00
Mark McDowall
a2050803a2 Fixed: Missing leading 0 in minutes/seconds for media info duration
Closes #4208
2020-12-31 11:50:51 -08:00
Mark McDowall
e5e86680c8 Fixed: Backup interval is updated on change
Closes #4207
2020-12-31 11:42:00 -08:00
Mark McDowall
ca34f64eb0 Fixed: Update path before importing to ensure it hasn't changed
Closes #4206
2020-12-30 14:01:01 -08:00
tenshiak
c7b950f213 Fixed: Parsing Polish language 2020-12-30 12:11:47 -08:00
bakerboy448
36f231ea24 New: Rename Import to Library Import 2020-12-30 11:04:53 -08:00
Taloth Saldono
b60d4f46d2 eslint 2020-12-25 16:14:56 +01:00
Taloth Saldono
090cdc364e Small helper in UI to access Sonarr API more easily 2020-12-24 23:55:39 +01:00
Taloth Saldono
182ad17b77 Fixed: Series year wrong when airing January 1st.
closes #4191
2020-12-24 12:43:46 +01:00
Taloth Saldono
026af22229 Fixed: OSX version detection
ref #4113 (not a fix, just partially)
2020-12-24 12:43:31 +01:00
Qstick
078898af91 Fixed: Format Errors from AudioChannel formatter 2020-12-18 16:34:50 -08:00
Mark McDowall
8b8deb5646 Fixed Migration 148 test 2020-12-18 16:28:45 -08:00
Qstick
314a12ffb5 Fixed: Handle 3 digit audio channels 2020-12-18 16:28:33 -08:00
Nathaniel Peiffer
7b04e11c54 Fixed: Language parsing with space-delimited releases
Closes #4056
2020-12-17 16:55:07 -08:00
Qstick
9180e7d6fd Fixed: Don't workaround DTS if audioChannels invalid 2020-12-17 16:54:05 -08:00
Qstick
b8b09cd0ce Fixed: Migrate Mediainfo properties that changed names 2020-12-17 16:54:05 -08:00
Qstick
39cb0934bc Fixed: Use audioChannels_Original if it exists in MI 2020-12-17 16:54:05 -08:00
Mark McDowall
b84f84ad0a Fixed health check wiki link unit tests 2020-12-16 14:52:29 -08:00
Robin Dadswell
55a7253dc2 New: Sorting Series List/Mass Editor by Language Profile and Tags
Closes #3854
2020-12-14 20:07:00 -08:00
bakerboy448
e733529dc3 All Wiki links now use the consolidated Servarr wiki 2020-12-13 11:07:12 -08:00
Robin Dadswell
cc39d4ee23 Fixed: '/series' URL Base breaking UI navigation
Closes #4148
2020-12-13 10:57:06 -08:00
Robin Dadswell
0ff889c3be New: Added Series Monitoring Toggle to Series Details
Closes #3991
2020-12-13 09:58:56 -08:00
Mark McDowall
39589b8c02 Move config.yml for github 2020-12-06 11:37:03 -08:00
Mark McDowall
c5c0462258 Fixed: Using folder as scene name for season packs 2020-12-06 11:34:01 -08:00
bakerboy448
dafcba7336 Update GitHub templates
- remove other issue template
- update support and add wiki
- prevent blank issues
- add support links
2020-12-01 09:38:53 -08:00
bakerboy448
19ff7bdc30 Fixed: List Import no longer fails due to duplicates
Closes #4100
2020-11-25 16:51:22 -08:00
Taloth Saldono
a9384e26d8 Removed unnecessary importlists warning. 2020-11-23 11:04:23 +01:00
Taloth Saldono
0bc190e97e Fixed binary execute permissions for osx and Radarr 2020-11-22 17:10:29 +01:00
Taloth Saldono
2c76afb839 Fixed disk permission tests 2020-11-21 22:30:33 +01:00
Taloth Saldono
0edb7b77a1 Reverted temporary dev debug code change 2020-11-21 15:27:10 +01:00
Mark McDowall
59bffa66ad Fixed: Monitor 'None' won't monitor latest season 2020-11-20 14:43:54 -08:00
Mark McDowall
145c644c9d New: Validate that naming formats don't contain illegal characters 2020-11-20 14:43:54 -08:00
Taloth Saldono
d88bb7f855 New: Displaying folder-based permissions in UI rather than file-based permissions and with selectable sane presets
Fixed: Preserve setgid when applying unix permissions
2020-11-20 15:42:23 +01:00
Antoine Martin
850552bf17 Update indexer category parameters for the other nyaa 2020-11-17 22:20:56 -08:00
Mark McDowall
66a19424af Added tests for using folder name as scene name 2020-11-16 23:25:42 -08:00
Taloth Saldono
a234293146 Credit where credit is due 2020-11-16 21:31:13 +01:00
Taloth Saldono
5fced70948 Give systemd a bit more time to restart sonarr after update 2020-11-16 21:24:55 +01:00
ta264
7a0e1818c0 Fixed: Import single file torrents with a folder from QBittorrent
Signed-off-by: Taloth Saldono <Taloth@users.noreply.github.com>
2020-11-16 21:06:11 +01:00
Mark McDowall
bd0e5e16b8 Fixed unit tests 2020-11-15 23:49:02 -08:00
Mark McDowall
487c664e43 Fixed: Scene Name not being stored properly during import if not linked to a download client item and filename is obfuscated 2020-11-15 18:18:23 -08:00
Mark McDowall
fed2a429c7 New: Don't process files during Manual Import if there are more than 100 items
Closes #1142
2020-11-15 18:18:23 -08:00
Taloth Saldono
ad9e709d96 Fixed duplicate UpdateHistory items 2020-11-15 22:19:48 +01:00
Mark McDowall
4c58ea63d6 Fixed: Tags in tag editor in SafarIE
Closes #4071
2020-11-15 11:00:21 -08:00
Taloth Saldono
e5ec4e706a Readded Movies cat to the end of the Newznab list 2020-11-14 22:43:37 +01:00
Taloth Saldono
158e31d54a Fixed: Truncating too long filenames with unicode characters
closes #4085
2020-11-14 22:33:23 +01:00
Taloth Saldono
05820ac272 Protect against Qbittorrent edgecase if users add torrents manually with Keep top-level folder disabled 2020-11-14 22:33:22 +01:00
Mark McDowall
fe0d8bb7da Return max tooltip width 2020-11-13 17:16:05 -08:00
Mark McDowall
6f74a9e3eb Fixed: Reprocessing Manual Import items resetting season number if no episodes were selected
Closes #4089
2020-11-13 17:12:10 -08:00
Mark McDowall
d90f50d589 Fixed: Language chosen in manual import overridden during import
Closes #4082
2020-11-13 17:12:10 -08:00
Mark McDowall
517fc2bd75 Fixed: Example file names for Daily Series
Closes #4078
2020-11-13 17:12:10 -08:00
Mark McDowall
b6316c9fcd Spelling confidence 2020-11-13 17:12:10 -08:00
Mark McDowall
675d948a1f Fixed: Manual Import breaking if quality is selected before series 2020-11-13 17:12:10 -08:00
Taloth Saldono
49bf3f4512 And forgot test of course 2020-11-14 02:02:12 +01:00
Taloth Saldono
3ff848b019 Fix for QBittorrent directory for specific torrents
fixes #4091
2020-11-14 01:52:28 +01:00
Taloth Saldono
8b2550cef0 Bumped Sabnzbd default history request size from 30 to 60 2020-11-14 00:00:29 +01:00
ta264
813f886920 Fixed: QBittorrent imports when torrent name and folder name differ
closes #3346 
fixes #3968
closes #4068
2020-11-13 23:15:58 +01:00
Mark McDowall
c75c546888 Fixed: Manual import when quality was selected before episodes
Closes #4065
2020-11-08 21:33:43 -08:00
Mark McDowall
baa41b2c13 Fixed: Correctly storing v0 version during import, allowing them to be upgraded to v1 later
Closes #4066
2020-11-07 11:55:28 -08:00
Mark McDowall
aeaaa4a77a Fixed: wiki link for removed series health check
Closes #4053
2020-11-01 16:01:40 -08:00
Mark McDowall
1025bdc76e Show .net version in UI
Closes #4057
2020-11-01 16:01:40 -08:00
Mark McDowall
0b7aa19ac0 Fixed: Telegram silent notifications
Closes #4041
2020-11-01 16:01:40 -08:00
Robin Dadswell
cfdaddd81a New: Discord notification upgrade colour
Closes #4044
2020-11-01 16:01:31 -08:00
Timothy Pillow
fd4c2c11ad Change 'Ignore Deleted Episodes' to 'Unmonitor Deleted Episodes' for consistency 2020-11-01 16:00:02 -08:00
Robin Dadswell
42d93f6fdb New: Changed colour of Discord On Download notifications
Closes #4036
2020-10-26 07:45:50 -07:00
Mark McDowall
0de3f10701 Fixed: 1080i HDTV H264 incorrectly being detected as Raw HD 2020-10-25 17:17:57 -07:00
Mark McDowall
7b1876d0d8 Fixed: Cleanse account and passwd from Download Station URLs 2020-10-25 16:12:45 -07:00
Mark McDowall
91c395d0c6 Fixed: Show TLS errors in UI when testing download clients
Closes #4021
2020-10-25 15:59:40 -07:00
Mark McDowall
3fc3aef268 Improved Trakt list validation
New: Able to add empty Trakt lists (shows warning during test)
Fixed: Show error in UI if Trakt list hasn't been authenticated

Closes #4022
2020-10-25 12:01:29 -07:00
Mark McDowall
fa2e70d571 Fixed: Show feed URL if incorrect mime type is found 2020-10-25 12:01:29 -07:00
Mark McDowall
c823654041 Fixed: Discord notifications failing if episode overview is missing 2020-10-25 12:01:29 -07:00
Mark McDowall
cfa0c6d0d7 Fixed phrasing when release match found by ID 2020-10-25 12:01:29 -07:00
Taloth Saldono
4d4319797b Updated debian install script to handle longer user names 2020-10-24 10:41:58 +02:00
Mark McDowall
e90e144ebc Fixed: Grab/Import fields for Discord notifications being duplicated 2020-10-18 19:24:21 -07:00
Qstick
f701adaef5 Pass no parameter instead of null parameter on Kodi Update 2020-10-18 17:50:07 -07:00
Mark McDowall
427ce17e6e Kodi GetMovies fails due to Parameter Type 2020-10-18 17:50:02 -07:00
Qstick
3a8522e92f Resource missing from Gotify call 2020-10-18 17:44:09 -07:00
Qstick
886e5581d8 Gotify token as query parameter 2020-10-18 17:43:57 -07:00
Qstick
470c9101ae New: Customizable Discord Notifications
Closes #3985
2020-10-18 17:09:26 -07:00
Qstick
09347f79c5 TagSelect field type 2020-10-18 16:27:00 -07:00
ta264
1d02208316 New: Make Twitter NetStandard compatible 2020-10-18 16:26:59 -07:00
Mark McDowall
0878f514aa New: Remove Growl notifications 2020-10-18 16:26:58 -07:00
Mark McDowall
1b32949443 Rename migration 144 2020-10-18 15:32:44 -07:00
Qstick
665b80f15c Convert Notifications from RestSharp to HttpClient 2020-10-18 15:22:24 -07:00
Mark McDowall
51528f63e9 Fixed: Parsing of some daily episodes 2020-10-18 15:07:26 -07:00
Mark McDowall
a7e9eebfed Fix namespace for BlacklistBulkResource 2020-10-18 15:07:26 -07:00
Qstick
897673b459 Improve use of All() and speedup Unmapped Folder matching
Co-authored-by: ta264 <ta264@users.noreply.github.com>
2020-10-18 14:59:26 -07:00
Mark McDowall
19f724dcd9 Fixed: Limit Raw HD detection by MPEG-2 to HDTV sources 2020-10-13 00:06:12 -07:00
Mark McDowall
4ad137f1eb Fixed: Size on disk sorting and display
Closes #4014
2020-10-12 16:27:21 -07:00
Mark McDowall
dab6242ff2 New: Ability to edit restriction terms in Release Profiles 2020-10-12 15:30:32 -07:00
Mark McDowall
d47ad37791 Fix installer branch/build from testing 2020-10-12 12:19:34 -07:00
Mark McDowall
2adedb97da New: Differentiate between short term and long term (more than 6 hours) indexer failures
Closes #3279
2020-10-12 11:24:19 -07:00
Mark McDowall
7dd64d843a Fixed: (Windows) clean up extraneous files in build folder during installation 2020-10-12 11:03:47 -07:00
Mark McDowall
f30ae69c10 New: Reprocess items after selection in Manual Import
Closes #3818
2020-10-12 10:49:35 -07:00
Mark McDowall
c871b3f948 Fixed: Copying passwords
Closes #4011
2020-10-11 16:42:07 -07:00
Mark McDowall
67f5628340 New: Bulk remove from Blacklist
Closes #3500
2020-10-11 15:28:41 -07:00
Mark McDowall
fae38a107f New: Warning when combining preferred words with a specific indexer 2020-10-11 15:28:41 -07:00
Mark McDowall
f35b8174aa Re-saving edited providers will forcibly save them 2020-10-11 15:28:40 -07:00
Taloth Saldono
465de11c90 Fixed: Regression causing updater to fail (manual update required if on 3.0.3.971, see forums) 2020-10-11 20:52:12 +02:00
384 changed files with 8983 additions and 2041 deletions

View File

@@ -1,41 +0,0 @@
<!--
Before opening a new issue, please ensure:
- You use the forums for support/questions
- You search for existing bugs/feature requests
- Remove extraneous template details
- Do not prefix title with type of issue (Feature Request, Bug, etc.) The appropriate labels will be added during triage.
-->
## Support / Questions
Please use https://forums.sonarr.tv/ for support. Support requests or questions will be redirected to the forums and the issue will be closed.
<!--
Remove if not opening a bug report
-->
## Bug Report
### System Information/Logs
**Sonarr Version:**
**Operating System:**
**.net Framework (Windows) or mono (macOS/Linux) Version:**
**Link to Log Files (debug or trace):**
**Browser (for UI bugs):**
### Additional Information
<!--
Remove if not opening a feature request
-->
## Feature Request
### What problem are you looking to solve?
### Other Information

View File

@@ -1,28 +1,36 @@
---
name: Bug report
about: Create a report to help us improve Sonarr
name: Bug Report
about: Support Requests will be closed immediately, if you are not 100% certain this is a bug please go to our Reddit, Discord, Forums, or IRC first. Exceptions do not mean you found a bug!
title: ''
labels: 'bug'
assignees: ''
---
<!-- Support Requests will be closed immediately, if you are not 100% certain this is a bug please go to our Reddit, Discord, Forums, or IRC first. Exceptions do not mean you found a bug! -->
**Describe the bug**
A clear and concise description of what the bug is.
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
<!-- Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error -->
**Expected behavior**
<!-- A clear and concise description of what you expected to happen.-->
**Screenshots**
If applicable, add screenshots to help explain your problem.
<!-- If applicable, add screenshots to help explain your problem.-->
**Logs**
Link to debug or trace log files.
**Platform Information (please complete the following information):**
- OS: <!-- [e.g. Windows 10 2004 / Ubuntu 20.04] -->
- Docker: <!-- [Yes/No] -->
- .net Framework (Windows) or mono (macOS/Linux) (System -> Status): <!--[e.g. Mono 5.8, Mono 6.2, .net 4.5] -->
- Browser and Version (Only needed for UI issues): <!--[e.g. chrome 86.0.4240.198] -->
- Sonarr Version: <!--[e.g. 2.0.0.5344 , 3.0.4.1077]-->
- Sonarr Branch: <!--[e.g. master, develop , phantom-develop]-->
**System Information**
- Sonarr Version: [e.g. 2.0.0.1]
- Operating System: [e.g. Windows 10]
- .net Framework (Windows) or mono (macOS/Linux) Version: [e.g. 4.5 or 5.12]
**UI Bugs:**
- OS: [e.g. Windows]
- Browser: [e.g. chrome, firefox]
- Version: [e.g. 22]
**Additional context**
Add any other context about the problem here.
**Trace Logs**
Turn on Trace logs under Settings -> General and wait for the bug to occur again.
**Upload the full log file here (or another site (e.g. pastebin) and link it). Issues will be closed, if they do not include this!**

14
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: Support via Discord
url: https://discord.gg/M6BvZn5
about: Chat with users and devs on support and setup related topics.
- name: Support via Reddit
url: https://reddit.com/r/Sonarr
about: Discuss and search through support topics.
- name: Support via Forums
url: https://forums.sonarr.tv/
about: Discuss and search through support topics.
- name: Support via IRC
url: http://webchat.freenode.net/?channels=#sonarr
about: Chat with users and devs on support and setup related topics.

View File

@@ -1,7 +0,0 @@
---
name: Other issues
about: How to get support or ask questions
---
Please use https://forums.sonarr.tv/ for support. Support requests or questions will be redirected to the forums and the issue will be closed.

View File

@@ -6,7 +6,7 @@ A few sentences describing the overall goals of the pull request's commits.
#### Todos
- [ ] Tests
- [ ] Documentation
- [ ] Wiki Updates
#### Issues Fixed or Closed by this PR

6
.github/SUPPORT.md vendored
View File

@@ -1,7 +1,7 @@
## Support
There are a number of frequently asked questions that have been answered in our [FAQ](https://github.com/Sonarr/Sonarr/wiki/FAQ)
There are a number of frequently asked questions that have been answered in our [FAQ](https://wiki.servarr.com/Sonarr_FAQ)
The [wiki](https://github.com/Sonarr/Sonarr/wiki) contains other information and guides
The [wiki](https://wiki.servarr.com/Sonarr) contains other information and guides
If you have a support question, please use the [support forums](https://forums.sonarr.tv/).
Please use one of the support channels: [forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/), [discord ](https://discord.gg/M6BvZn5), or [IRC ](http://webchat.freenode.net/?channels=#sonarr)for support/questions.

21
.github/workflows/lock.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: 'Lock threads'
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * *'
jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v2
with:
github-token: ${{ github.token }}
issue-lock-inactive-days: '90'
issue-exclude-created-before: ''
issue-exclude-labels: 'one-day-maybe'
issue-lock-labels: ''
issue-lock-comment: ''
issue-lock-reason: 'resolved'
process-only: ''

1
.gitignore vendored
View File

@@ -141,3 +141,4 @@ output/*
_start
src/.idea/
/distribution/windows/setup/output/*

View File

@@ -3,25 +3,40 @@
We're always looking for people to help make Sonarr even better, there are a number of ways to contribute.
## Documentation ##
Setup guides, FAQ, the more information we have on the wiki the better.
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 ##
See the readme for information on setting up your development environment.
### 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 ###
1. Fork Sonarr
2. Clone the repository into your development machine. [*info*](https://help.github.com/articles/working-with-repositories)
3. Install the required Node Packages `yarn install`
4. Start gulp to monitor your dev environment for any 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 ###
- 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 develop (currently phantom-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 the forums or on IRC if you have any questions
- 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](http://webchat.freenode.net/?channels=#sonarr) if you have any questions
- Add tests (unit/integration)
- Commit with *nix line endings for consistency (We checkout Windows and commit *nix)
- One feature/bug fix per pull request to keep things clean and easy to understand
- Use 4 spaces instead of tabs, this is the default for VS 2012 and WebStorm (to my knowledge)
- Use 4 spaces instead of tabs, this should be the default for VS 2019 and WebStorm
### Pull Requesting ###
- Only make pull requests to develop, never master, if you make a PR to master we'll comment on it and close it
- Only make pull requests to develop (currently phantom-develop), never master, if you make a PR to master 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

@@ -4,17 +4,23 @@ Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS fee
## Getting Started
- [Download](https://sonarr.tv/#download) (Linux, MacOS, Windows, Docker, etc.)
- [Installation](https://github.com/Sonarr/Sonarr/wiki/Installation)
- [FAQ](https://github.com/Sonarr/Sonarr/wiki/FAQ)
- [Wiki](https://github.com/Sonarr/Sonarr/wiki)
- [API Documentation](https://github.com/Sonarr/Sonarr/wiki/API)
- [Download/Installation](https://sonarr.tv/#downloads-v3)
- [FAQ](https://wiki.servarr.com/Sonarr_FAQ)
- [Wiki](https://wiki.servarr.com/Sonarr)
- [(WIP) API Documentation](https://github.com/Sonarr/Sonarr/wiki/API)
- [Donate](https://sonarr.tv/donate)
## Support
Note: GitHub Issues are for Bugs and Feature Requests Only
- [Donate](https://sonarr.tv/donate)
- [Forums](https://forums.sonarr.tv/)
- [Discord](https://discord.gg/M6BvZn5)
- [GitHub - Bugs and Feature Requests Only](https://github.com/Sonarr/Sonarr/issues)
- [IRC ](http://webchat.freenode.net/?channels=#sonarr)
- [Reddit](https://www.reddit.com/r/sonarr)
- [Wiki](https://wiki.servarr.com/Sonarr)
## Features
@@ -32,43 +38,16 @@ Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS fee
- Full support for specials and multi-episode releases
- And a beautiful UI
## Configuring Development Environment:
## Contributing
### Requirements
- [Visual Studio 2017](https://www.visualstudio.com/vs)
- [Git](https://git-scm.com/downloads)
- [NodeJS](https://nodejs.org/en/download)
- [Yarn](https://yarnpkg.com)
### Setup
- Make sure all the required software mentioned above are installed
- Clone the repository recursively to get Sonarr and it's submodules
- You can do this by running `git clone --recursive https://github.com/Sonarr/Sonarr.git`
- Install the required Node Packages using `yarn`
### Backend Development
- Run `yarn build` to build the UI
- Open `Sonarr.sln` in Visual Studio
- Make sure `Sonarr.Console` is set as the startup project
- Build `Sonarr.Windows` and `Sonarr.Mono` projects
- Build Solution
### UI Development
- Run `yarn watch` to build UI and rebuild automatically when changes are detected
- Run Sonarr.Console.exe (or debug in Visual Studio)
### Licenses
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2020
### Development
This project exists thanks to all the people who contribute. [Contribute](CONTRIBUTING.md).
<a href="https://github.com/Sonarr/Sonarr/graphs/contributors"><img src="https://opencollective.com/Sonarr/contributors.svg?width=890&button=false" /></a>
### Supporters
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!
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!
#### Sponsors
@@ -90,3 +69,7 @@ Thank you to [<img src="/Logo/Jetbrains/jetbrains.svg" alt="JetBrains" width="32
* [<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/)
### Licenses
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2021

View File

@@ -9,7 +9,7 @@ UPDATER={updater}
# Existing nzbdrone packages do not have startup scripts and the process might still be running.
# If the user manually installed nzbdrone then the process might still be running too.
if [ $1 = "install" ]; then
psNzbDrone=`ps ax -o'user,pid,ppid,unit,args' | grep mono.*NzbDrone\\\\.exe || true`
psNzbDrone=`ps ax -o'user:20,pid,ppid,unit,args' | grep mono.*NzbDrone\\\\.exe || true`
if [ ! -z "$psNzbDrone" ]; then
# Get the user and optional systemd unit
psNzbDroneUser=`echo "$psNzbDrone" | tr -s ' ' | cut -d ' ' -f 1`

View File

@@ -37,6 +37,7 @@ Compression=lzma2/normal
AppContact={#ForumsURL}
VersionInfoVersion={#BuildNumber}
SetupLogging=yes
OutputDir=output
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
@@ -59,6 +60,7 @@ Name: "{userstartup}\{#AppName}"; Filename: "{app}\Sonarr.exe"; WorkingDir: "{ap
[InstallDelete]
Name: "{commonappdata}\NzbDrone\bin"; Type: filesandordirs
Name: "{app}"; Type: filesandordirs
[Run]
Filename: "{app}\Sonarr.Console.exe"; StatusMsg: "Removing previous Windows Service"; Parameters: "/u"; Flags: runhidden waituntilterminated;

View File

@@ -17,6 +17,9 @@ RUN fromdos /startup.sh
WORKDIR /data/
VOLUME ["/data/_tests_linux", "/data/_output_linux", "/data/_tests_results"]
RUN groupadd sonarrtst -g 4020 && useradd sonarrtst -u 4021 -g 4020 -m -s /bin/bash
USER sonarrtst
CMD bash /startup.sh

View File

@@ -25,5 +25,8 @@ RUN fromdos /startup.sh
WORKDIR /data/
VOLUME ["/data/_tests_linux", "/data/_output_linux", "/data/_tests_results"]
RUN groupadd sonarrtst -g 4020 && useradd sonarrtst -u 4021 -g 4020 -m -s /bin/bash
USER sonarrtst
CMD bash /startup.sh

View File

@@ -10,7 +10,8 @@ gulp.task('build',
'webpack',
'copyHtml',
'copyFonts',
'copyImages'
'copyImages',
'copyRobots'
)
)
);

View File

@@ -32,3 +32,11 @@ gulp.task('copyImages', () => {
.pipe(gulp.dest(paths.dest.root))
.pipe(livereload());
});
gulp.task('copyRobots', () => {
return gulp.src(paths.src.robots, { base: paths.src.root })
.pipe(cache('copyRobots'))
.pipe(print())
.pipe(gulp.dest(paths.dest.root))
.pipe(livereload());
});

View File

@@ -8,6 +8,7 @@ const paths = {
content: `${root}/Content/`,
fonts: `${root}/Content/Fonts/`,
images: `${root}/Content/Images/`,
robots: `${root}/Content/robots.txt`,
exclude: {
libs: `!${root}/JsLibraries/**`
}

View File

@@ -13,6 +13,7 @@ function watch() {
gulpWatch(paths.src.html, gulp.series('copyHtml'));
gulpWatch(`${paths.src.fonts}**/*.*`, gulp.series('copyFonts'));
gulpWatch(`${paths.src.images}**/*.*`, gulp.series('copyImages'));
gulpWatch(paths.src.robots, gulp.series('copyRobots'));
}
gulp.task('watch', gulp.series('build', watch));

View File

@@ -1,7 +1,14 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { align, icons } from 'Helpers/Props';
import getRemovedItems from 'Utilities/Object/getRemovedItems';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
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 { align, icons, kinds } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
@@ -15,6 +22,72 @@ import BlacklistRowConnector from './BlacklistRowConnector';
class Blacklist extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
isConfirmRemoveModalOpen: 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 });
}
//
// Render
@@ -26,15 +99,33 @@ class Blacklist extends Component {
items,
columns,
totalRecords,
isRemoving,
isClearingBlacklistExecuting,
onClearBlacklistPress,
...otherProps
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
isConfirmRemoveModalOpen
} = this.state;
const selectedIds = this.getSelectedIds();
return (
<PageContent title="Blacklist">
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Remove Selected"
iconName={icons.REMOVE}
isDisabled={!selectedIds.length}
isSpinning={isRemoving}
onPress={this.onRemoveSelectedPress}
/>
<PageToolbarButton
label="Clear"
iconName={icons.CLEAR}
@@ -59,51 +150,67 @@ class Blacklist extends Component {
<PageContentBody>
{
isFetching && !isPopulated &&
<LoadingIndicator />
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load blacklist</div>
<div>Unable to load blacklist</div>
}
{
isPopulated && !error && !items.length &&
<div>
No history blacklist
</div>
<div>
No history blacklist
</div>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
columns={columns}
{...otherProps}
>
<TableBody>
{
items.map((item) => {
return (
<BlacklistRowConnector
key={item.id}
columns={columns}
{...item}
/>
);
})
}
</TableBody>
</Table>
<div>
<Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<BlacklistRowConnector
key={item.id}
isSelected={selectedState[item.id] || false}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
</div>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
</div>
}
</PageContentBody>
<ConfirmModal
isOpen={isConfirmRemoveModalOpen}
kind={kinds.DANGER}
title="Remove Selected"
message={'Are you sure you want to remove the selected items from the blacklist?'}
confirmLabel="Remove Selected"
onConfirm={this.onRemoveSelectedConfirmed}
onCancel={this.onConfirmRemoveModalClose}
/>
</PageContent>
);
}
@@ -116,7 +223,9 @@ Blacklist.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isRemoving: PropTypes.bool.isRequired,
isClearingBlacklistExecuting: PropTypes.bool.isRequired,
onRemoveSelected: PropTypes.func.isRequired,
onClearBlacklistPress: PropTypes.func.isRequired
};

View File

@@ -89,6 +89,10 @@ class BlacklistConnector extends Component {
this.props.gotoBlacklistPage({ page });
}
onRemoveSelected = (ids) => {
this.props.removeBlacklistItems({ ids });
}
onSortPress = (sortKey) => {
this.props.setBlacklistSort({ sortKey });
}
@@ -124,6 +128,7 @@ class BlacklistConnector extends Component {
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onRemoveSelected={this.onRemoveSelected}
onSortPress={this.onSortPress}
onTableOptionChange={this.onTableOptionChange}
onClearBlacklistPress={this.onClearBlacklistPress}
@@ -143,6 +148,7 @@ BlacklistConnector.propTypes = {
gotoBlacklistNextPage: PropTypes.func.isRequired,
gotoBlacklistLastPage: PropTypes.func.isRequired,
gotoBlacklistPage: PropTypes.func.isRequired,
removeBlacklistItems: PropTypes.func.isRequired,
setBlacklistSort: PropTypes.func.isRequired,
setBlacklistTableOption: PropTypes.func.isRequired,
clearBlacklist: PropTypes.func.isRequired,

View File

@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -40,6 +41,7 @@ class BlacklistRow extends Component {
render() {
const {
id,
series,
sourceTitle,
language,
@@ -48,12 +50,20 @@ class BlacklistRow extends Component {
protocol,
indexer,
message,
isSelected,
columns,
onSelectedChange,
onRemovePress
} = this.props;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
@@ -179,7 +189,9 @@ BlacklistRow.propTypes = {
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
};

View File

@@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { removeFromBlacklist } from 'Store/Actions/blacklistActions';
import { removeBlacklistItem } from 'Store/Actions/blacklistActions';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import BlacklistRow from './BlacklistRow';
@@ -18,7 +18,7 @@ function createMapStateToProps() {
function createMapDispatchToProps(dispatch, props) {
return {
onRemovePress() {
dispatch(removeFromBlacklist({ id: props.id }));
dispatch(removeBlacklistItem({ id: props.id }));
}
};
}

View File

@@ -31,6 +31,8 @@ class Queue extends Component {
constructor(props, context) {
super(props, context);
this._shouldBlockRefresh = false;
this.state = {
allSelected: false,
allUnselected: false,
@@ -42,6 +44,18 @@ class Queue extends Component {
};
}
shouldComponentUpdate(nextProps) {
if (!this._shouldBlockRefresh) {
return true;
}
if (hasDifferentItems(this.props.items, nextProps.items)) {
return false;
}
return true;
}
componentDidUpdate(prevProps) {
const {
items,
@@ -82,6 +96,10 @@ class Queue extends Component {
//
// Listeners
onQueueRowModalOpenOrClose = (isOpen) => {
this._shouldBlockRefresh = isOpen;
}
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
}
@@ -97,16 +115,19 @@ class Queue extends Component {
}
onRemoveSelectedPress = () => {
this._shouldBlockRefresh = true;
this.setState({ isConfirmRemoveModalOpen: true });
}
onRemoveSelectedConfirmed = (payload) => {
this.props.onRemoveSelectedPress({ ids: this.getSelectedIds(), ...payload });
this.setState({ isConfirmRemoveModalOpen: false });
this._shouldBlockRefresh = false;
}
onConfirmRemoveModalClose = () => {
this.setState({ isConfirmRemoveModalOpen: false });
this._shouldBlockRefresh = false;
}
//
@@ -205,7 +226,7 @@ class Queue extends Component {
}
{
isPopulated && !hasError && !items.length &&
isAllPopulated && !hasError && !items.length &&
<div>
Queue is empty
</div>
@@ -234,6 +255,7 @@ class Queue extends Component {
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
onQueueRowModalOpenOrClose={this.onQueueRowModalOpenOrClose}
/>
);
})

View File

@@ -42,19 +42,32 @@ class QueueRow extends Component {
}
onRemoveQueueItemModalConfirmed = (blacklist) => {
this.props.onRemoveQueueItemPress(blacklist);
const {
onRemoveQueueItemPress,
onQueueRowModalOpenOrClose
} = this.props;
onQueueRowModalOpenOrClose(false);
onRemoveQueueItemPress(blacklist);
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 });
}
@@ -397,7 +410,8 @@ QueueRow.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelectedChange: PropTypes.func.isRequired,
onGrabPress: PropTypes.func.isRequired,
onRemoveQueueItemPress: PropTypes.func.isRequired
onRemoveQueueItemPress: PropTypes.func.isRequired,
onQueueRowModalOpenOrClose: PropTypes.func.isRequired
};
QueueRow.defaultProps = {

View File

@@ -154,7 +154,7 @@ class AddNewSeries extends Component {
<div className={styles.noResults}>Couldn't find any results for '{term}'</div>
<div>You can also search using TVDB ID of a show. eg. tvdb:71663</div>
<div>
<Link to="https://github.com/Sonarr/Sonarr/wiki/FAQ#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
<Link to="https://wiki.servarr.com/Sonarr_FAQ#Why_cant_I_add_a_new_series_when_I_know_the_TVDB_ID">
Why can't I find my show?
</Link>
</div>

View File

@@ -78,7 +78,10 @@ class ImportSeriesSelectFolder extends Component {
Make sure that your files include the quality in their filenames. eg. <span className={styles.code}>episode.s02e15.bluray.mkv</span>
</li>
<li className={styles.tip}>
Point Sonarr to the folder containing all of your tv shows, not a specific one. eg. <span className={styles.code}>"{isWindows ? 'C:\\tv shows' : '/tv shows'}"</span> and not <span className={styles.code}>"{isWindows ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons'}"</span>
Point Sonarr to the folder containing all of your tv shows, not a specific one. eg. <span className={styles.code}>"{isWindows ? 'C:\\tv shows' : '/tv shows'}"</span> and not <span className={styles.code}>"{isWindows ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons'}"</span> Additionally, each series must be in its own folder within the root/library folder.
</li>
<li className={styles.tip}>
Do not use for importing downloads from your download client, this is only for existing organized libraries, not unsorted files.
</li>
</ul>
</div>

View File

@@ -25,7 +25,7 @@
}
.time {
flex: 0 0 120px;
flex: 0 0 125px;
margin-right: 10px;
border: none !important;
}

View File

@@ -128,7 +128,7 @@ class FileBrowserModalContent extends Component {
className={styles.mappedDrivesWarning}
kind={kinds.WARNING}
>
Mapped network drives are not available when running as a Windows Service, see the <Link className={styles.faqLink} to="https://github.com/Sonarr/Sonarr/wiki/FAQ">FAQ</Link> for more information.
Mapped network drives are not available when running as a Windows Service, see the <Link className={styles.faqLink} to="https://wiki.servarr.com/Sonarr_FAQ#Why_cant_Sonarr_see_my_files_on_a_remote_server">FAQ</Link> for more information.
</Alert>
}

View File

@@ -12,6 +12,7 @@ import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue';
import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector';
import styles from './FilterBuilderRow.css';
@@ -75,6 +76,9 @@ function getRowValueConnector(selectedFilterBuilderProp) {
case filterBuilderValueTypes.SERIES_STATUS:
return SeriesStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES_TYPES:
return SeriesTypeFilterBuilderRowValue;
case filterBuilderValueTypes.TAG:
return TagFilterBuilderRowValueConnector;

View File

@@ -1,5 +1,5 @@
.tag {
height: 21px;
display: flex;
&.isLastTag {
.or {
@@ -18,4 +18,5 @@
.or {
margin: 0 3px;
color: $themeDarkColor;
line-height: 31px;
}

View File

@@ -6,7 +6,7 @@ import styles from './FilterBuilderRowValueTag.css';
function FilterBuilderRowValueTag(props) {
return (
<span
<div
className={styles.tag}
>
<TagInputTag
@@ -15,12 +15,13 @@ function FilterBuilderRowValueTag(props) {
/>
{
!props.isLastTag &&
<span className={styles.or}>
props.isLastTag ?
null :
<div className={styles.or}>
or
</span>
</div>
}
</span>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const seriesTypeList = [
{ id: 'anime', name: 'Anime' },
{ id: 'daily', name: 'Daily' },
{ id: 'standard', name: 'Standard' }
];
function SeriesTypeFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={seriesTypeList}
{...props}
/>
);
}
export default SeriesTypeFilterBuilderRowValue;

View File

@@ -5,6 +5,10 @@
align-items: center;
}
.editableContainer {
width: 100%;
}
.hasError {
composes: hasError from '~Components/Form/Input.css';
}
@@ -22,6 +26,16 @@
margin-left: 12px;
}
.dropdownArrowContainerEditable {
position: absolute;
top: 0;
right: 0;
padding-right: 17px;
width: 30%;
height: 35px;
text-align: right;
}
.dropdownArrowContainerDisabled {
composes: dropdownArrowContainer;

View File

@@ -15,6 +15,7 @@ import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import Scroller from 'Components/Scroller/Scroller';
import TextInput from './TextInput';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import HintedSelectInputOption from './HintedSelectInputOption';
import styles from './EnhancedSelectInput.css';
@@ -169,11 +170,21 @@ class EnhancedSelectInput extends Component {
}
}
onFocus = () => {
if (this.state.isOpen) {
this._removeListener();
this.setState({ isOpen: false });
}
}
onBlur = () => {
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
const origIndex = getSelectedIndex(this.props);
if (origIndex !== this.state.selectedIndex) {
this.setState({ selectedIndex: origIndex });
if (!this.props.isEditable) {
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
const origIndex = getSelectedIndex(this.props);
if (origIndex !== this.state.selectedIndex) {
this.setState({ selectedIndex: origIndex });
}
}
}
@@ -297,16 +308,19 @@ class EnhancedSelectInput extends Component {
const {
className,
disabledClassName,
name,
value,
values,
isDisabled,
isEditable,
isFetching,
hasError,
hasWarning,
valueOptions,
selectedValueOptions,
selectedValueComponent: SelectedValueComponent,
optionComponent: OptionComponent
optionComponent: OptionComponent,
onChange
} = this.props;
const {
@@ -332,52 +346,94 @@ class EnhancedSelectInput extends Component {
whitelist={['width']}
onMeasure={this.onMeasure}
>
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
value={value}
values={values}
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : null}
</SelectedValueComponent>
{
isEditable ?
<div
className={styles.editableContainer}
>
<TextInput
className={className}
name={name}
value={value}
readOnly={isDisabled}
hasError={hasError}
hasWarning={hasWarning}
onFocus={this.onFocus}
onBlur={this.onBlur}
onChange={onChange}
/>
<Link
className={classNames(
styles.dropdownArrowContainerEditable,
isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer)
}
onPress={this.onPress}
>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
<div
className={isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer
}
>
{
!isFetching &&
<Icon
name={icons.CARET_DOWN}
/>
}
</Link>
</div> :
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
value={value}
values={values}
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : null}
</SelectedValueComponent>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
<div
className={isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer
}
>
{
!isFetching &&
<Icon
name={icons.CARET_DOWN}
/>
}
</div>
</Link>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
{
!isFetching &&
<Icon
name={icons.CARET_DOWN}
/>
}
</div>
</Link>
}
</Measure>
</div>
)}
@@ -502,6 +558,7 @@ EnhancedSelectInput.propTypes = {
values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isEditable: PropTypes.bool.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
valueOptions: PropTypes.object.isRequired,
@@ -517,6 +574,7 @@ EnhancedSelectInput.defaultProps = {
disabledClassName: styles.isDisabled,
isDisabled: false,
isFetching: false,
isEditable: false,
valueOptions: {},
selectedValueOptions: {},
selectedValueComponent: HintedSelectInputSelectedValue,

View File

@@ -20,8 +20,10 @@ import SeriesTypeSelectInput from './SeriesTypeSelectInput';
import EnhancedSelectInput from './EnhancedSelectInput';
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
import TagInputConnector from './TagInputConnector';
import TagSelectInputConnector from './TagSelectInputConnector';
import TextTagInputConnector from './TextTagInputConnector';
import TextInput from './TextInput';
import UMaskInput from './UMaskInput';
import FormInputHelpText from './FormInputHelpText';
import styles from './FormInputGroup.css';
@@ -84,6 +86,12 @@ function getComponent(type) {
case inputTypes.TEXT_TAG:
return TextTagInputConnector;
case inputTypes.TAG_SELECT:
return TagSelectInputConnector;
case inputTypes.UMASK:
return UMaskInput;
default:
return TextInput;
}
@@ -191,7 +199,7 @@ function FormInputGroup(props) {
}
{
!checkInput && helpTextWarning &&
(!checkInput || helpText) && helpTextWarning &&
<FormInputHelpText
text={helpTextWarning}
isWarning={true}

View File

@@ -3,10 +3,17 @@ import React from 'react';
import TextInput from './TextInput';
import styles from './PasswordInput.css';
// Prevent a user from copying (or cutting) the password from the input
function onCopy(e) {
e.preventDefault();
e.nativeEvent.stopImmediatePropagation();
}
function PasswordInput(props) {
return (
<TextInput
{...props}
onCopy={onCopy}
/>
);
}

View File

@@ -29,6 +29,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.SELECT;
case 'tag':
return inputTypes.TEXT_TAG;
case 'tagSelect':
return inputTypes.TAG_SELECT;
case 'textbox':
return inputTypes.TEXT;
case 'oAuth':

View File

@@ -75,6 +75,12 @@ class TagInput extends Component {
//
// Listeners
onTagEdit = ({ value, ...otherProps }) => {
this.setState({ value });
this.props.onTagDelete(otherProps);
}
onInputContainerPress = () => {
this._autosuggestRef.input.focus();
}
@@ -188,6 +194,7 @@ class TagInput extends Component {
const {
tags,
kind,
canEdit,
tagComponent,
onTagDelete
} = this.props;
@@ -199,8 +206,10 @@ class TagInput extends Component {
kind={kind}
inputProps={inputProps}
isFocused={this.state.isFocused}
canEdit={canEdit}
tagComponent={tagComponent}
onTagDelete={onTagDelete}
onTagEdit={this.onTagEdit}
onInputContainerPress={this.onInputContainerPress}
/>
);
@@ -262,6 +271,7 @@ TagInput.propTypes = {
placeholder: PropTypes.string.isRequired,
delimiters: PropTypes.arrayOf(PropTypes.string).isRequired,
minQueryLength: PropTypes.number.isRequired,
canEdit: PropTypes.bool,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
tagComponent: PropTypes.elementType.isRequired,
@@ -277,6 +287,7 @@ TagInput.defaultProps = {
placeholder: '',
delimiters: ['Tab', 'Enter', ' ', ','],
minQueryLength: 1,
canEdit: false,
tagComponent: TagInputTag
};

View File

@@ -28,8 +28,10 @@ class TagInputInput extends Component {
tags,
inputProps,
kind,
canEdit,
tagComponent: TagComponent,
onTagDelete
onTagDelete,
onTagEdit
} = this.props;
return (
@@ -47,8 +49,10 @@ class TagInputInput extends Component {
index={index}
tag={tag}
kind={kind}
canEdit={canEdit}
isLastTag={index === tags.length - 1}
onDelete={onTagDelete}
onEdit={onTagEdit}
/>
);
})
@@ -67,8 +71,10 @@ TagInputInput.propTypes = {
inputProps: PropTypes.object.isRequired,
kind: PropTypes.oneOf(kinds.all).isRequired,
isFocused: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
tagComponent: PropTypes.elementType.isRequired,
onTagDelete: PropTypes.func.isRequired,
onTagEdit: PropTypes.func.isRequired,
onInputContainerPress: PropTypes.func.isRequired
};

View File

@@ -1,5 +1,19 @@
.tag {
composes: link from '~Components/Link/Link.css';
display: flex;
justify-content: center;
flex-direction: column;
height: 31px;
}
.editContainer {
display: inline-block;
margin-left: 4px;
padding-left: 2px;
border-left: 1px solid #eee;
}
.editButton {
composes: button from '~Components/Link/IconButton.css';
width: auto;
}

View File

@@ -1,8 +1,9 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds } from 'Helpers/Props';
import { icons, kinds } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import styles from './TagInputTag.css';
@@ -24,24 +25,59 @@ class TagInputTag extends Component {
});
}
onEdit = () => {
const {
index,
tag,
onEdit
} = this.props;
onEdit({
index,
id: tag.id,
value: tag.name
});
}
//
// Render
render() {
const {
tag,
kind
kind,
canEdit
} = this.props;
return (
<Link
<div
className={styles.tag}
tabIndex={-1}
onPress={this.onDelete}
>
<Label kind={kind}>
{tag.name}
<Label
kind={kind}
>
<Link
tabIndex={-1}
onPress={this.onDelete}
>
{tag.name}
</Link>
{
canEdit ?
<div className={styles.editContainer}>
<IconButton
className={styles.editButton}
name={icons.EDIT}
size={9}
onPress={this.onEdit}
/>
</div> :
null
}
</Label>
</Link>
</div>
);
}
}
@@ -50,7 +86,9 @@ TagInputTag.propTypes = {
index: PropTypes.number.isRequired,
tag: PropTypes.shape(tagShape),
kind: PropTypes.oneOf(kinds.all).isRequired,
onDelete: PropTypes.func.isRequired
canEdit: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired
};
export default TagInputTag;

View File

@@ -0,0 +1,102 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import TagInput from './TagInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
(state, { values }) => values,
(tags, tagList) => {
const sortedTags = _.sortBy(tagList, 'value');
return {
tags: tags.reduce((acc, tag) => {
const matchingTag = _.find(tagList, { key: tag });
if (matchingTag) {
acc.push({
id: tag,
name: matchingTag.value
});
}
return acc;
}, []),
tagList: sortedTags.map(({ key: id, value: name }) => {
return {
id,
name
};
}),
allTags: sortedTags
};
}
);
}
class TagSelectInputConnector extends Component {
//
// Listeners
onTagAdd = (tag) => {
const {
name,
value,
allTags
} = this.props;
const existingTag =_.some(allTags, { key: tag.id });
const newValue = value.slice();
if (existingTag) {
newValue.push(tag.id);
}
this.props.onChange({ name, value: newValue });
}
onTagDelete = ({ index }) => {
const {
name,
value
} = this.props;
const newValue = value.slice();
newValue.splice(index, 1);
this.props.onChange({
name,
value: newValue
});
}
//
// Render
render() {
return (
<TagInput
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
{...this.props}
/>
);
}
}
TagSelectInputConnector.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.number).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
allTags: PropTypes.arrayOf(PropTypes.object).isRequired,
onChange: PropTypes.func.isRequired
};
export default connect(createMapStateToProps)(TagSelectInputConnector);

View File

@@ -130,7 +130,8 @@ class TextInput extends Component {
step,
min,
max,
onBlur
onBlur,
onCopy
} = this.props;
return (
@@ -155,6 +156,8 @@ class TextInput extends Component {
onChange={this.onChange}
onFocus={this.onFocus}
onBlur={onBlur}
onCopy={onCopy}
onCut={onCopy}
onKeyUp={this.onKeyUp}
onMouseDown={this.onMouseDown}
onMouseUp={this.onMouseUp}
@@ -180,6 +183,7 @@ TextInput.propTypes = {
onChange: PropTypes.func.isRequired,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
onCopy: PropTypes.func,
onSelectionChange: PropTypes.func
};

View File

@@ -0,0 +1,53 @@
.inputWrapper {
display: flex;
}
.inputFolder {
composes: input from '~Components/Form/Input.css';
max-width: 100px;
}
.inputUnitWrapper {
position: relative;
width: 100%;
}
.inputUnit {
composes: inputUnit from '~Components/Form/FormInputGroup.css';
right: 40px;
font-family: $monoSpaceFontFamily;
}
.unit {
font-family: $monoSpaceFontFamily;
}
.details {
margin-top: 5px;
margin-left: 17px;
line-height: 20px;
> div {
display: flex;
label {
flex: 0 0 50px;
}
.value {
width: 50px;
text-align: right;
}
.unit {
width: 90px;
text-align: right;
}
}
}
.readOnly {
background-color: #eee;
}

View File

@@ -0,0 +1,133 @@
/* eslint-disable no-bitwise */
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styles from './UMaskInput.css';
import EnhancedSelectInput from './EnhancedSelectInput';
const umaskOptions = [
{
key: '755',
value: '755 - Owner write, Everyone else read',
hint: 'drwxr-xr-x'
},
{
key: '775',
value: '775 - Owner & Group write, Other read',
hint: 'drwxrwxr-x'
},
{
key: '770',
value: '770 - Owner & Group write',
hint: 'drwxrwx---'
},
{
key: '750',
value: '750 - Owner write, Group read',
hint: 'drwxr-x---'
},
{
key: '777',
value: '777 - Everyone write',
hint: 'drwxrwxrwx'
}
];
function formatPermissions(permissions) {
const hasSticky = permissions & 0o1000;
const hasSetGID = permissions & 0o2000;
const hasSetUID = permissions & 0o4000;
let result = '';
for (let i = 0; i < 9; i++) {
const bit = (permissions & (1 << i)) !== 0;
let digit = bit ? 'xwr'[i % 3] : '-';
if (i === 6 && hasSetUID) {
digit = bit ? 's' : 'S';
} else if (i === 3 && hasSetGID) {
digit = bit ? 's' : 'S';
} else if (i === 0 && hasSticky) {
digit = bit ? 't' : 'T';
}
result = digit + result;
}
return result;
}
class UMaskInput extends Component {
//
// Render
render() {
const {
name,
value,
onChange
} = this.props;
const valueNum = parseInt(value, 8);
const umaskNum = 0o777 & ~valueNum;
const umask = umaskNum.toString(8).padStart(4, '0');
const folderNum = 0o777 & ~umaskNum;
const folder = folderNum.toString(8).padStart(3, '0');
const fileNum = 0o666 & ~umaskNum;
const file = fileNum.toString(8).padStart(3, '0');
const unit = formatPermissions(folderNum);
const values = umaskOptions.map((v) => {
return { ...v, hint: <span className={styles.unit}>{v.hint}</span> };
});
return (
<div>
<div className={styles.inputWrapper}>
<div className={styles.inputUnitWrapper}>
<EnhancedSelectInput
name={name}
value={value}
values={values}
isEditable={true}
onChange={onChange}
/>
<div className={styles.inputUnit}>
d{unit}
</div>
</div>
</div>
<div className={styles.details}>
<div>
<label>UMask</label>
<div className={styles.value}>{umask}</div>
</div>
<div>
<label>Folder</label>
<div className={styles.value}>{folder}</div>
<div className={styles.unit}>d{formatPermissions(folderNum)}</div>
</div>
<div>
<label>File</label>
<div className={styles.value}>{file}</div>
<div className={styles.unit}>{formatPermissions(fileNum)}</div>
</div>
</div>
</div>
);
}
}
UMaskInput.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onFocus: PropTypes.func,
onBlur: PropTypes.func
};
export default UMaskInput;

View File

@@ -47,10 +47,6 @@ class Link extends Component {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else if (to.startsWith(`${window.Sonarr.urlBase}/`)) {
el = RouterLink;
linkProps.to = to;
linkProps.target = target;
} else {
el = RouterLink;
linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`;

View File

@@ -53,7 +53,7 @@ class PageHeader extends Component {
<div className={styles.logoContainer}>
<Link
className={styles.logoLink}
to={`${window.Sonarr.urlBase}/`}
to={'/'}
>
<img
className={styles.logo}

View File

@@ -29,7 +29,7 @@ const links = [
to: '/add/new'
},
{
title: 'Import',
title: 'Library Import',
to: '/add/import'
},
{

View File

@@ -22,6 +22,8 @@ function getMaxWidth() {
} else {
maxWidth = 450;
}
return maxWidth;
}
class Tooltip extends Component {

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -0,0 +1,120 @@
import createAjaxRequest from 'Utilities/createAjaxRequest';
// This file contains some helpers for power users in a browser console
let hasWarned = false;
function checkActivationWarning() {
if (!hasWarned) {
console.log('Activated SonarrApi console helpers.');
console.warn('Be warned: There will be no further confirmation checks.');
hasWarned = true;
}
}
function attachAsyncActions(promise) {
promise.filter = function() {
const args = arguments;
const res = this.then((d) => d.filter(...args));
attachAsyncActions(res);
return res;
};
promise.map = function() {
const args = arguments;
const res = this.then((d) => d.map(...args));
attachAsyncActions(res);
return res;
};
promise.all = function() {
const res = this.then((d) => Promise.all(d));
attachAsyncActions(res);
return res;
};
promise.forEach = function(action) {
const res = this.then((d) => Promise.all(d.map(action)));
attachAsyncActions(res);
return res;
};
}
class ResourceApi {
constructor(api, url) {
this.api = api;
this.url = url;
}
single(id) {
return this.api.fetch(`${this.url}/${id}`);
}
all() {
return this.api.fetch(this.url);
}
filter(pred) {
return this.all().filter(pred);
}
update(resource) {
return this.api.fetch(`${this.url}/${resource.id}`, { method: 'PUT', data: resource });
}
delete(resource) {
if (typeof resource === 'object' && resource !== null && resource.id) {
resource = resource.id;
}
if (!resource || !Number.isInteger(resource)) {
throw Error('Invalid resource', resource);
}
return this.api.fetch(`${this.url}/${resource}`, { method: 'DELETE' });
}
fetch(url, options) {
return this.api.fetch(`${this.url}${url}`, options);
}
}
class ConsoleApi {
constructor() {
this.series = new ResourceApi(this, '/series');
}
resource(url) {
return new ResourceApi(this, url);
}
fetch(url, options) {
checkActivationWarning();
options = options || {};
const req = {
url,
method: options.method || 'GET'
};
if (options.data) {
req.dataType = 'json';
req.data = JSON.stringify(options.data);
}
const promise = createAjaxRequest(req).request;
promise.fail((xhr) => {
console.error(`Failed to fetch ${url}`, xhr);
});
attachAsyncActions(promise);
return promise;
}
}
window.SonarrApi = new ConsoleApi();
export default ConsoleApi;

View File

@@ -1,22 +1,13 @@
import PropTypes from 'prop-types';
import React, { Fragment } from 'react';
import padNumber from 'Utilities/Number/padNumber';
import filterAlternateTitles from 'Utilities/Series/filterAlternateTitles';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import SceneInfo from './SceneInfo';
import styles from './EpisodeNumber.css';
function getAlternateTitles(seasonNumber, sceneSeasonNumber, alternateTitles) {
return alternateTitles.filter((alternateTitle) => {
if (sceneSeasonNumber && sceneSeasonNumber === alternateTitle.sceneSeasonNumber) {
return true;
}
return seasonNumber === alternateTitle.seasonNumber;
});
}
function getWarningMessage(unverifiedSceneNumbering, seriesType, absoluteEpisodeNumber) {
const messages = [];
@@ -39,13 +30,14 @@ function EpisodeNumber(props) {
sceneSeasonNumber,
sceneEpisodeNumber,
sceneAbsoluteEpisodeNumber,
useSceneNumbering,
unverifiedSceneNumbering,
alternateTitles: seriesAlternateTitles,
seriesType,
showSeasonNumber
} = props;
const alternateTitles = getAlternateTitles(seasonNumber, sceneSeasonNumber, seriesAlternateTitles);
const alternateTitles = filterAlternateTitles(seriesAlternateTitles, null, useSceneNumbering, seasonNumber, sceneSeasonNumber);
const hasSceneInformation = sceneSeasonNumber !== undefined ||
sceneEpisodeNumber !== undefined ||
@@ -81,6 +73,8 @@ function EpisodeNumber(props) {
title="Scene Information"
body={
<SceneInfo
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
sceneSeasonNumber={sceneSeasonNumber}
sceneEpisodeNumber={sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={sceneAbsoluteEpisodeNumber}
@@ -131,6 +125,7 @@ EpisodeNumber.propTypes = {
sceneSeasonNumber: PropTypes.number,
sceneEpisodeNumber: PropTypes.number,
sceneAbsoluteEpisodeNumber: PropTypes.number,
useSceneNumbering: PropTypes.bool.isRequired,
unverifiedSceneNumbering: PropTypes.bool.isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
seriesType: PropTypes.string,
@@ -138,6 +133,7 @@ EpisodeNumber.propTypes = {
};
EpisodeNumber.defaultProps = {
useSceneNumbering: false,
unverifiedSceneNumbering: false,
alternateTitles: [],
showSeasonNumber: false

View File

@@ -15,3 +15,8 @@
margin-left: 100px;
}
.comment {
color: $darkGray;
font-size: $smallFontSize;
}

View File

@@ -7,6 +7,8 @@ import styles from './SceneInfo.css';
function SceneInfo(props) {
const {
seasonNumber,
episodeNumber,
sceneSeasonNumber,
sceneEpisodeNumber,
sceneAbsoluteEpisodeNumber,
@@ -56,14 +58,33 @@ function SceneInfo(props) {
<div>
{
alternateTitles.map((alternateTitle) => {
let suffix = '';
const altSceneSeasonNumber = sceneSeasonNumber === undefined ? seasonNumber : sceneSeasonNumber;
const altSceneEpisodeNumber = sceneEpisodeNumber === undefined ? episodeNumber : sceneEpisodeNumber;
const mappingSeasonNumber = alternateTitle.sceneOrigin === 'tvdb' ? seasonNumber : altSceneSeasonNumber;
const altSeasonNumber = (alternateTitle.sceneSeasonNumber !== -1 && alternateTitle.sceneSeasonNumber !== undefined) ? alternateTitle.sceneSeasonNumber : mappingSeasonNumber;
const altEpisodeNumber = alternateTitle.sceneOrigin === 'tvdb' ? episodeNumber : altSceneEpisodeNumber;
if (altEpisodeNumber !== altSceneEpisodeNumber) {
suffix = `S${padNumber(altSeasonNumber, 2)}E${padNumber(altEpisodeNumber, 2)}`;
} else if (altSeasonNumber !== altSceneSeasonNumber) {
suffix = `S${padNumber(altSeasonNumber, 2)}`;
}
return (
<div
key={alternateTitle.title}
>
{alternateTitle.title}
{
alternateTitle.sceneSeasonNumber !== -1 &&
<span> (S{padNumber(alternateTitle.sceneSeasonNumber, 2)})</span>
suffix &&
<span> ({suffix})</span>
}
{
alternateTitle.comment &&
<span className={styles.comment}> {alternateTitle.comment}</span>
}
</div>
);
@@ -78,6 +99,8 @@ function SceneInfo(props) {
}
SceneInfo.propTypes = {
seasonNumber: PropTypes.number,
episodeNumber: PropTypes.number,
sceneSeasonNumber: PropTypes.number,
sceneEpisodeNumber: PropTypes.number,
sceneAbsoluteEpisodeNumber: PropTypes.number,

View File

@@ -179,6 +179,7 @@ export const RESTORE = fasHistory;
export const REORDER = fasBars;
export const RSS = fasRss;
export const SAVE = fasSave;
export const SCENE_MAPPING = fasSitemap;
export const SCHEDULED = farClock;
export const SCORE = fasUserPlus;
export const SEARCH = fasSearch;

View File

@@ -18,6 +18,8 @@ export const SERIES_TYPE_SELECT = 'seriesTypeSelect';
export const TAG = 'tag';
export const TEXT = 'text';
export const TEXT_TAG = 'textTag';
export const TAG_SELECT = 'tagSelect';
export const UMASK = 'umask';
export const all = [
AUTO_COMPLETE,
@@ -39,5 +41,7 @@ export const all = [
SERIES_TYPE_SELECT,
TAG,
TEXT,
TEXT_TAG
TEXT_TAG,
TAG_SELECT,
UMASK
];

View File

@@ -93,6 +93,7 @@ class SelectEpisodeModalContent extends Component {
error,
items,
relativePath,
isAnime,
sortKey,
sortDirection,
onSortPress,
@@ -172,8 +173,10 @@ class SelectEpisodeModalContent extends Component {
key={item.id}
id={item.id}
episodeNumber={item.episodeNumber}
absoluteEpisodeNumber={item.absoluteEpisodeNumber}
title={item.title}
airDate={item.airDate}
isAnime={isAnime}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/>
@@ -229,6 +232,7 @@ SelectEpisodeModalContent.propTypes = {
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
relativePath: PropTypes.string,
isAnime: PropTypes.bool.isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
onSortPress: PropTypes.func.isRequired,

View File

@@ -6,7 +6,8 @@ import {
updateInteractiveImportItem,
fetchInteractiveImportEpisodes,
setInteractiveImportEpisodesSort,
clearInteractiveImportEpisodes
clearInteractiveImportEpisodes,
reprocessInteractiveImportItems
} from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import SelectEpisodeModalContent from './SelectEpisodeModalContent';
@@ -21,10 +22,11 @@ function createMapStateToProps() {
}
const mapDispatchToProps = {
fetchInteractiveImportEpisodes,
setInteractiveImportEpisodesSort,
clearInteractiveImportEpisodes,
updateInteractiveImportItem
dispatchFetchInteractiveImportEpisodes: fetchInteractiveImportEpisodes,
dispatchSetInteractiveImportEpisodesSort: setInteractiveImportEpisodesSort,
dispatchClearInteractiveImportEpisodes: clearInteractiveImportEpisodes,
dispatchUpdateInteractiveImportItem: updateInteractiveImportItem,
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems
};
class SelectEpisodeModalContentConnector extends Component {
@@ -38,26 +40,28 @@ class SelectEpisodeModalContentConnector extends Component {
seasonNumber
} = this.props;
this.props.fetchInteractiveImportEpisodes({ seriesId, seasonNumber });
this.props.dispatchFetchInteractiveImportEpisodes({ seriesId, seasonNumber });
}
componentWillUnmount() {
// This clears the episodes for the queue and hides the queue
// We'll need another place to store episodes for manual import
this.props.clearInteractiveImportEpisodes();
this.props.dispatchClearInteractiveImportEpisodes();
}
//
// Listeners
onSortPress = (sortKey, sortDirection) => {
this.props.setInteractiveImportEpisodesSort({ sortKey, sortDirection });
this.props.dispatchSetInteractiveImportEpisodesSort({ sortKey, sortDirection });
}
onEpisodesSelect = (episodeIds) => {
const {
ids,
items,
dispatchUpdateInteractiveImportItem,
dispatchReprocessInteractiveImportItems,
onModalClose
} = this.props;
@@ -78,12 +82,14 @@ class SelectEpisodeModalContentConnector extends Component {
const startingIndex = index * episodesPerFile;
const episodes = sortedEpisodes.slice(startingIndex, startingIndex + episodesPerFile);
this.props.updateInteractiveImportItem({
dispatchUpdateInteractiveImportItem({
id,
episodes
});
});
dispatchReprocessInteractiveImportItems({ ids });
onModalClose(true);
}
@@ -106,10 +112,11 @@ SelectEpisodeModalContentConnector.propTypes = {
seriesId: PropTypes.number.isRequired,
seasonNumber: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchInteractiveImportEpisodes: PropTypes.func.isRequired,
setInteractiveImportEpisodesSort: PropTypes.func.isRequired,
clearInteractiveImportEpisodes: PropTypes.func.isRequired,
updateInteractiveImportItem: PropTypes.func.isRequired,
dispatchFetchInteractiveImportEpisodes: PropTypes.func.isRequired,
dispatchSetInteractiveImportEpisodesSort: PropTypes.func.isRequired,
dispatchClearInteractiveImportEpisodes: PropTypes.func.isRequired,
dispatchUpdateInteractiveImportItem: PropTypes.func.isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -25,8 +25,10 @@ class SelectEpisodeRow extends Component {
const {
id,
episodeNumber,
absoluteEpisodeNumber,
title,
airDate,
isAnime,
isSelected,
onSelectedChange
} = this.props;
@@ -41,6 +43,7 @@ class SelectEpisodeRow extends Component {
<TableRowCell>
{episodeNumber}
{isAnime ? ` (${absoluteEpisodeNumber})` : ''}
</TableRowCell>
<TableRowCell>
@@ -58,8 +61,10 @@ class SelectEpisodeRow extends Component {
SelectEpisodeRow.propTypes = {
id: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
airDate: PropTypes.string.isRequired,
isAnime: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired
};

View File

@@ -124,7 +124,7 @@ class InteractiveImportSelectFolderModalContent extends Component {
name={icons.QUICK}
/>
Quick Import
Move Automatically
</Button>
</div>

View File

@@ -187,8 +187,17 @@ class InteractiveImportRow extends Component {
} = this.state;
const seriesTitle = series ? series.title : '';
const episodeNumbers = episodes.map((episode) => episode.episodeNumber)
.join(', ');
const isAnime = series ? series.seriesType === 'anime' : false;
const episodeInfo = episodes.map((episode) => {
return (
<div key={episode.id}>
{episode.episodeNumber}
{isAnime ? ` (${episode.absoluteEpisodeNumber})` : ''}
{` - ${episode.title}`}
</div>
);
});
const showSeriesPlaceholder = isSelected && !series;
const showSeasonNumberPlaceholder = isSelected && !!series && isNaN(seasonNumber) && !isReprocessing;
@@ -246,7 +255,7 @@ class InteractiveImportRow extends Component {
onPress={this.onSelectEpisodePress}
>
{
showEpisodeNumbersPlaceholder ? <InteractiveImportRowCellPlaceholder /> : episodeNumbers
showEpisodeNumbersPlaceholder ? <InteractiveImportRowCellPlaceholder /> : episodeInfo
}
</TableRowCellButton>
@@ -339,6 +348,7 @@ class InteractiveImportRow extends Component {
isOpen={isSelectEpisodeModalOpen}
ids={[id]}
seriesId={series && series.id}
isAnime={isAnime}
seasonNumber={seasonNumber}
relativePath={relativePath}
onModalClose={this.onSelectEpisodeModalClose}

View File

@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal';
import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector';
import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector';
@@ -47,6 +48,7 @@ class InteractiveImportModal extends Component {
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>

View File

@@ -4,7 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchLanguageProfileSchema } from 'Store/Actions/settingsActions';
import { updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import { updateInteractiveImportItems, reprocessInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import SelectLanguageModalContent from './SelectLanguageModalContent';
function createMapStateToProps() {
@@ -30,7 +30,8 @@ function createMapStateToProps() {
const mapDispatchToProps = {
dispatchFetchLanguageProfileSchema: fetchLanguageProfileSchema,
dispatchUpdateInteractiveImportItems: updateInteractiveImportItems
dispatchUpdateInteractiveImportItems: updateInteractiveImportItems,
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems
};
class SelectLanguageModalContentConnector extends Component {
@@ -48,15 +49,23 @@ class SelectLanguageModalContentConnector extends Component {
// Listeners
onLanguageSelect = ({ value }) => {
const {
ids,
dispatchUpdateInteractiveImportItems,
dispatchReprocessInteractiveImportItems
} = this.props;
const languageId = parseInt(value);
const language = _.find(this.props.items,
(item) => item.language.id === languageId).language;
this.props.dispatchUpdateInteractiveImportItems({
ids: this.props.ids,
dispatchUpdateInteractiveImportItems({
ids,
language
});
dispatchReprocessInteractiveImportItems({ ids });
this.props.onModalClose(true);
}
@@ -81,6 +90,7 @@ SelectLanguageModalContentConnector.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchFetchLanguageProfileSchema: PropTypes.func.isRequired,
dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -5,7 +5,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import getQualities from 'Utilities/Quality/getQualities';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import { updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import { updateInteractiveImportItems, reprocessInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import SelectQualityModalContent from './SelectQualityModalContent';
function createMapStateToProps() {
@@ -31,7 +31,8 @@ function createMapStateToProps() {
const mapDispatchToProps = {
dispatchFetchQualityProfileSchema: fetchQualityProfileSchema,
dispatchUpdateInteractiveImportItems: updateInteractiveImportItems
dispatchUpdateInteractiveImportItems: updateInteractiveImportItems,
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems
};
class SelectQualityModalContentConnector extends Component {
@@ -49,6 +50,12 @@ class SelectQualityModalContentConnector extends Component {
// Listeners
onQualitySelect = ({ qualityId, proper, real }) => {
const {
ids,
dispatchUpdateInteractiveImportItems,
dispatchReprocessInteractiveImportItems
} = this.props;
const quality = _.find(this.props.items,
(item) => item.id === qualityId);
@@ -57,14 +64,16 @@ class SelectQualityModalContentConnector extends Component {
real: real ? 1 : 0
};
this.props.dispatchUpdateInteractiveImportItems({
ids: this.props.ids,
dispatchUpdateInteractiveImportItems({
ids,
quality: {
quality,
revision
}
});
dispatchReprocessInteractiveImportItems({ ids });
this.props.onModalClose(true);
}
@@ -89,6 +98,7 @@ SelectQualityModalContentConnector.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -28,7 +28,7 @@ class SelectSeriesModalContent extends Component {
// Listeners
onFilterChange = ({ value }) => {
this.setState({ filter: value.toLowerCase() });
this.setState({ filter: value });
}
//
@@ -42,6 +42,7 @@ class SelectSeriesModalContent extends Component {
} = this.props;
const filter = this.state.filter;
const filterLower = filter.toLowerCase();
return (
<ModalContent onModalClose={onModalClose}>
@@ -68,7 +69,7 @@ class SelectSeriesModalContent extends Component {
>
{
items.map((item) => {
return item.title.toLowerCase().includes(filter) ?
return item.title.toLowerCase().includes(filterLower) ?
(
<SelectSeriesRow
key={item.id}

View File

@@ -7,9 +7,16 @@
.title {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
display: flex;
align-items: center;
justify-content: space-between;
word-break: break-all;
}
.sceneMapping {
flex-shrink: 0;
}
.indexer {
composes: cell from '~Components/Table/Cells/TableRowCell.css';

View File

@@ -14,6 +14,7 @@ import Popover from 'Components/Tooltip/Popover';
import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeQuality from 'Episode/EpisodeQuality';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import ReleaseSceneIndicator from './ReleaseSceneIndicator';
import Peers from './Peers';
import styles from './InteractiveSearchRow.css';
@@ -114,8 +115,17 @@ class InteractiveSearchRow extends Component {
quality,
language,
preferredWordScore,
sceneMapping,
seasonNumber,
episodeNumbers,
absoluteEpisodeNumbers,
mappedSeasonNumber,
mappedEpisodeNumbers,
mappedAbsoluteEpisodeNumbers,
rejections,
episodeRequested,
downloadAllowed,
isDaily,
isGrabbing,
isGrabbed,
longDateFormat,
@@ -142,6 +152,18 @@ class InteractiveSearchRow extends Component {
<Link to={infoUrl}>
{title}
</Link>
<ReleaseSceneIndicator
className={styles.sceneMapping}
seasonNumber={mappedSeasonNumber}
episodeNumbers={mappedEpisodeNumbers}
absoluteEpisodeNumbers={mappedAbsoluteEpisodeNumbers}
sceneSeasonNumber={seasonNumber}
sceneEpisodeNumbers={episodeNumbers}
sceneAbsoluteEpisodeNumbers={absoluteEpisodeNumbers}
sceneMapping={sceneMapping}
episodeRequested={episodeRequested}
isDaily={isDaily}
/>
</TableRowCell>
<TableRowCell className={styles.indexer}>
@@ -245,8 +267,17 @@ InteractiveSearchRow.propTypes = {
quality: PropTypes.object.isRequired,
language: PropTypes.object.isRequired,
preferredWordScore: PropTypes.number.isRequired,
sceneMapping: PropTypes.object,
seasonNumber: PropTypes.number,
episodeNumbers: PropTypes.arrayOf(PropTypes.number),
absoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
mappedSeasonNumber: PropTypes.number,
mappedEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
mappedAbsoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
episodeRequested: PropTypes.bool.isRequired,
downloadAllowed: PropTypes.bool.isRequired,
isDaily: PropTypes.bool.isRequired,
isGrabbing: PropTypes.bool.isRequired,
isGrabbed: PropTypes.bool.isRequired,
grabError: PropTypes.string,

View File

@@ -0,0 +1,66 @@
.container {
margin: 2px;
padding: 0 2px;
border: 1px solid;
border-radius: 2px;
white-space: nowrap;
font-size: 12px;
cursor: default;
}
.messages {
margin-top: 15px;
}
.descriptionList {
composes: descriptionList from '~Components/DescriptionList/DescriptionList.css';
margin-right: 10px;
}
.title {
composes: title from '~Components/DescriptionList/DescriptionListItemTitle.css';
width: 80px;
}
.description {
composes: title from '~Components/DescriptionList/DescriptionListItemDescription.css';
margin-left: 100px;
}
.levelMixed {
border-color: $dangerColor;
color: $dangerColor;
}
.levelUnknown {
border-color: $warningColor;
color: $warningColor;
}
.levelMapped {
border-color: $textColor;
color: $textColor;
}
.levelNormal {
border-color: $textColor;
color: $textColor;
}
.levelNone {
border-color: $textColor;
color: $textColor;
opacity: 0.2;
&:hover {
opacity: 1;
}
}
.levelNotRequested {
border-color: $dangerColor;
color: $dangerColor;
}

View File

@@ -0,0 +1,191 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import { tooltipPositions, icons, sizes } from 'Helpers/Props';
import styles from './ReleaseSceneIndicator.css';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Popover from 'Components/Tooltip/Popover';
import Icon from 'Components/Icon';
function formatReleaseNumber(seasonNumber, episodeNumbers, absoluteEpisodeNumbers) {
if (episodeNumbers && episodeNumbers.length) {
if (episodeNumbers.length > 1) {
return `${seasonNumber}x${episodeNumbers[0]}-${episodeNumbers[episodeNumbers.length - 1]}`;
}
return `${seasonNumber}x${episodeNumbers[0]}`;
}
if (absoluteEpisodeNumbers && absoluteEpisodeNumbers.length) {
if (absoluteEpisodeNumbers.length > 1) {
return `${absoluteEpisodeNumbers[0]}-${absoluteEpisodeNumbers[absoluteEpisodeNumbers.length - 1]}`;
}
return absoluteEpisodeNumbers[0];
}
if (seasonNumber !== undefined) {
return `Season ${seasonNumber}`;
}
return null;
}
function ReleaseSceneIndicator(props) {
const {
className,
seasonNumber,
episodeNumbers,
absoluteEpisodeNumbers,
sceneSeasonNumber,
sceneEpisodeNumbers,
sceneAbsoluteEpisodeNumbers,
sceneMapping,
episodeRequested,
isDaily
} = props;
const {
sceneOrigin,
title,
comment
} = sceneMapping || {};
if (isDaily) {
return null;
}
let mappingDifferent = (sceneSeasonNumber !== undefined && seasonNumber !== sceneSeasonNumber);
if (sceneEpisodeNumbers !== undefined) {
mappingDifferent = mappingDifferent || !_.isEqual(sceneEpisodeNumbers, episodeNumbers);
} else if (sceneAbsoluteEpisodeNumbers !== undefined) {
mappingDifferent = mappingDifferent || !_.isEqual(sceneAbsoluteEpisodeNumbers, absoluteEpisodeNumbers);
}
if (!sceneMapping && !mappingDifferent) {
return null;
}
const releaseNumber = formatReleaseNumber(sceneSeasonNumber, sceneEpisodeNumbers, sceneAbsoluteEpisodeNumbers);
const mappedNumber = formatReleaseNumber(seasonNumber, episodeNumbers, absoluteEpisodeNumbers);
const messages = [];
const isMixed = (sceneOrigin === 'mixed');
const isUnknown = (sceneOrigin === 'unknown' || sceneOrigin === 'unknown:tvdb');
let level = styles.levelNone;
if (isMixed) {
level = styles.levelMixed;
messages.push(<div>{comment ?? 'Source'} releases exist with ambiguous numbering, unable to reliably identify episode.</div>);
} else if (isUnknown) {
level = styles.levelUnknown;
messages.push(<div>Numbering varies for this episode and release does not match any known mappings.</div>);
if (sceneOrigin === 'unknown') {
messages.push(<div>Assuming Scene numbering.</div>);
} else if (sceneOrigin === 'unknown:tvdb') {
messages.push(<div>Assuming TheTVDB numbering.</div>);
}
} else if (mappingDifferent) {
level = styles.levelMapped;
} else if (sceneOrigin) {
level = styles.levelNormal;
}
if (!episodeRequested) {
if (!isMixed && !isUnknown) {
level = styles.levelNotRequested;
}
if (mappedNumber) {
messages.push(<div>Mapped episode wasn't requested in this search.</div>);
} else {
messages.push(<div>Unknown episode or series.</div>);
}
}
const table = (
<DescriptionList className={styles.descriptionList}>
{
comment !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Mapping"
data={comment}
/>
}
{
title !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Title"
data={title}
/>
}
{
releaseNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Release"
data={releaseNumber ?? 'unknown'}
/>
}
{
releaseNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="TheTVDB"
data={mappedNumber ?? 'unknown'}
/>
}
</DescriptionList>
);
return (
<Popover
anchor={
<div className={classNames(level, styles.container, className)}>
<Icon
name={icons.SCENE_MAPPING}
size={sizes.SMALL}
/>
</div>
}
title="Scene Info"
body={
<div>
{table}
{
messages.length &&
<div className={styles.messages}>
{messages}
</div> || null
}
</div>
}
position={tooltipPositions.RIGHT}
/>
);
}
ReleaseSceneIndicator.propTypes = {
className: PropTypes.string.isRequired,
seasonNumber: PropTypes.number,
episodeNumbers: PropTypes.arrayOf(PropTypes.number),
absoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
sceneSeasonNumber: PropTypes.number,
sceneEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
sceneAbsoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
sceneMapping: PropTypes.object.isRequired,
episodeRequested: PropTypes.bool.isRequired,
isDaily: PropTypes.bool.isRequired
};
export default ReleaseSceneIndicator;

View File

@@ -11,7 +11,7 @@ function createMapStateToProps() {
return {
sectionItems,
filterBuilderProps,
customFilterType: 'seasonPass'
customFilterType: 'series'
};
}
);

View File

@@ -18,10 +18,10 @@ class SeasonPassRow extends Component {
render() {
const {
seriesId,
status,
titleSlug,
title,
monitored,
status,
title,
titleSlug,
seasons,
isSaving,
isSelected,
@@ -84,10 +84,10 @@ class SeasonPassRow extends Component {
SeasonPassRow.propTypes = {
seriesId: PropTypes.number.isRequired,
status: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
seasons: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,

View File

@@ -60,6 +60,7 @@ class EpisodeRow extends Component {
sceneAbsoluteEpisodeNumber,
airDateUtc,
title,
useSceneNumbering,
unverifiedSceneNumbering,
isSaving,
seriesMonitored,
@@ -110,6 +111,7 @@ class EpisodeRow extends Component {
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
absoluteEpisodeNumber={absoluteEpisodeNumber}
useSceneNumbering={useSceneNumbering}
unverifiedSceneNumbering={unverifiedSceneNumbering}
seriesType={seriesType}
sceneSeasonNumber={sceneSeasonNumber}
@@ -265,6 +267,7 @@ EpisodeRow.propTypes = {
airDateUtc: PropTypes.string,
title: PropTypes.string.isRequired,
isSaving: PropTypes.bool,
useSceneNumbering: PropTypes.bool,
unverifiedSceneNumbering: PropTypes.bool,
seriesMonitored: PropTypes.bool.isRequired,
seriesType: PropTypes.string.isRequired,

View File

@@ -11,6 +11,7 @@ function createMapStateToProps() {
createEpisodeFileSelector(),
(series = {}, episodeFile) => {
return {
useSceneNumbering: series.useSceneNumbering,
seriesMonitored: series.monitored,
seriesType: series.seriesType,
episodeFilePath: episodeFile ? episodeFile.path : null,

View File

@@ -1,3 +1,8 @@
.alternateTitle {
white-space: nowrap;
}
.comment {
color: $darkGray;
font-size: $smallFontSize;
}

View File

@@ -9,10 +9,14 @@ function SeriesAlternateTitles({ alternateTitles }) {
alternateTitles.map((alternateTitle) => {
return (
<li
key={alternateTitle}
key={alternateTitle.title}
className={styles.alternateTitle}
>
{alternateTitle}
{alternateTitle.title}
{
alternateTitle.comment &&
<span className={styles.comment}> {alternateTitle.comment}</span>
}
</li>
);
})

View File

@@ -34,6 +34,7 @@ import SeriesAlternateTitles from './SeriesAlternateTitles';
import SeriesDetailsSeasonConnector from './SeriesDetailsSeasonConnector';
import SeriesTagsConnector from './SeriesTagsConnector';
import SeriesDetailsLinks from './SeriesDetailsLinks';
import MonitoringOptionsModal from 'Series/MonitoringOptions/MonitoringOptionsModal';
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
import styles from './SeriesDetails.css';
@@ -71,6 +72,7 @@ class SeriesDetails extends Component {
isDeleteSeriesModalOpen: false,
isSeriesHistoryModalOpen: false,
isInteractiveImportModalOpen: false,
isMonitorOptionsModalOpen: false,
allExpanded: false,
allCollapsed: false,
expandedState: {},
@@ -132,6 +134,14 @@ class SeriesDetails extends Component {
this.setState({ isSeriesHistoryModalOpen: false });
}
onMonitorOptionsPress = () => {
this.setState({ isMonitorOptionsModalOpen: true });
}
onMonitorOptionsClose = () => {
this.setState({ isMonitorOptionsModalOpen: false });
}
onExpandAllPress = () => {
const {
allExpanded,
@@ -211,6 +221,7 @@ class SeriesDetails extends Component {
isDeleteSeriesModalOpen,
isSeriesHistoryModalOpen,
isInteractiveImportModalOpen,
isMonitorOptionsModalOpen,
allExpanded,
allCollapsed,
expandedState,
@@ -288,6 +299,12 @@ class SeriesDetails extends Component {
<PageToolbarSeparator />
<PageToolbarButton
label="Series Monitoring"
iconName={icons.MONITORED}
onPress={this.onMonitorOptionsPress}
/>
<PageToolbarButton
label="Edit"
iconName={icons.EDIT}
@@ -299,6 +316,7 @@ class SeriesDetails extends Component {
iconName={icons.DELETE}
onPress={this.onDeleteSeriesPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
@@ -426,7 +444,7 @@ class SeriesDetails extends Component {
<span className={styles.sizeOnDisk}>
{
formatBytes(sizeOnDisk)
formatBytes(sizeOnDisk || 0)
}
</span>
</Label>
@@ -646,6 +664,12 @@ class SeriesDetails extends Component {
showImportMode={false}
onModalClose={this.onInteractiveImportModalClose}
/>
<MonitoringOptionsModal
isOpen={isMonitorOptionsModalOpen}
seriesId={id}
onModalClose={this.onMonitorOptionsClose}
/>
</PageContentBody>
</PageContent>
);
@@ -664,6 +688,7 @@ SeriesDetails.propTypes = {
statistics: PropTypes.object.isRequired,
qualityProfileId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
monitor: PropTypes.string,
status: PropTypes.string.isRequired,
network: PropTypes.string,
overview: PropTypes.string.isRequired,
@@ -672,6 +697,7 @@ SeriesDetails.propTypes = {
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isRefreshing: PropTypes.bool.isRequired,
isSearching: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,

View File

@@ -5,6 +5,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import filterAlternateTitles from 'Utilities/Series/filterAlternateTitles';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions';
@@ -109,14 +110,7 @@ function createMapStateToProps() {
const isFetching = isEpisodesFetching || isEpisodeFilesFetching;
const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
const alternateTitles = _.reduce(series.alternateTitles, (acc, alternateTitle) => {
if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) &&
(alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) {
acc.push(alternateTitle.title);
}
return acc;
}, []);
const alternateTitles = filterAlternateTitles(series.alternateTitles, series.title, series.useSceneNumbering);
return {
...series,

View File

@@ -323,7 +323,7 @@ class SeriesDetailsSeason extends Component {
<MenuContent className={styles.actionsMenuContent}>
<MenuItem
isDisabled={isSearching || !hasMonitoredEpisodes}
isDisabled={isSearching || !hasMonitoredEpisodes || !seriesMonitored}
onPress={onSearchPress}
>
<SpinnerIcon
@@ -389,10 +389,10 @@ class SeriesDetailsSeason extends Component {
<SpinnerIconButton
className={styles.actionButton}
name={icons.SEARCH}
title={hasMonitoredEpisodes ? 'Search for monitored episodes in this season' : 'No monitored episodes in this season'}
title={hasMonitoredEpisodes && seriesMonitored ? 'Search for monitored episodes in this season' : 'No monitored episodes in this season'}
size={24}
isSpinning={isSearching}
isDisabled={isSearching || !hasMonitoredEpisodes}
isDisabled={isSearching || !hasMonitoredEpisodes || !seriesMonitored}
onPress={onSearchPress}
/>

View File

@@ -11,7 +11,7 @@ function createMapStateToProps() {
return {
sectionItems,
filterBuilderProps,
customFilterType: 'seriesEditor'
customFilterType: 'series'
};
}
);

View File

@@ -27,17 +27,17 @@ class SeriesEditorRow extends Component {
render() {
const {
id,
status,
titleSlug,
title,
monitored,
languageProfile,
qualityProfile,
status,
title,
titleSlug,
seriesType,
seasonFolder,
qualityProfile,
languageProfile,
path,
statistics = {},
tags,
seasonFolder,
statistics = {},
columns,
isSelected,
onSelectedChange

View File

@@ -144,6 +144,15 @@ function SeriesIndexSortMenu(props) {
>
Size on Disk
</SortMenuItem>
<SortMenuItem
name="tags"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Tags
</SortMenuItem>
</MenuContent>
</SortMenu>
);

View File

@@ -97,7 +97,8 @@ class SeriesIndexPoster extends Component {
seasonCount,
episodeCount,
episodeFileCount,
totalEpisodeCount
totalEpisodeCount,
sizeOnDisk
} = statistics;
const {
@@ -226,6 +227,7 @@ class SeriesIndexPoster extends Component {
<SeriesIndexPosterInfo
seasonCount={seasonCount}
sizeOnDisk={sizeOnDisk}
qualityProfile={qualityProfile}
showQualityProfile={showQualityProfile}
showRelativeDates={showRelativeDates}

View File

@@ -11,7 +11,7 @@ function createMapStateToProps() {
return {
sectionItems,
filterBuilderProps,
customFilterType: 'seriesIndex'
customFilterType: 'series'
};
}
);

View File

@@ -0,0 +1,39 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import MonitoringOptionsModal from './EditSeriesModal';
const mapDispatchToProps = {
clearPendingChanges
};
class MonitoringOptionsModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'series' });
this.props.onModalClose();
}
//
// Render
render() {
return (
<MonitoringOptionsModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
MonitoringOptionsModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(undefined, mapDispatchToProps)(MonitoringOptionsModalConnector);

View File

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

View File

@@ -0,0 +1,132 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
const NO_CHANGE = 'noChange';
class MonitoringOptionsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
monitor: NO_CHANGE
};
}
componentDidUpdate(prevProps) {
const {
isSaving,
saveError
} = prevProps;
if (prevProps.isSaving && !isSaving && !saveError) {
this.setState({
monitor: NO_CHANGE
});
}
}
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
}
//
// Listeners
onSavePress = () => {
const {
onSavePress
} = this.props;
const {
monitor
} = this.state;
if (monitor !== NO_CHANGE) {
onSavePress({ monitor });
}
}
//
// Render
render() {
const {
isSaving,
onInputChange,
onModalClose,
...otherProps
} = this.props;
const {
monitor
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Monitor Series
</ModalHeader>
<ModalBody>
<Form {...otherProps}>
<FormGroup>
<FormLabel>Monitoring</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
value={monitor}
includeNoChange={true}
onChange={this.onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerButton
isSpinning={isSaving}
onPress={this.onSavePress}
>
Save
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
MonitoringOptionsModalContent.propTypes = {
seriesId: PropTypes.number.isRequired,
saveError: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
MonitoringOptionsModalContent.defaultProps = {
isSaving: false
};
export default MonitoringOptionsModalContent;

View File

@@ -0,0 +1,77 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { updateSeriesMonitor } from 'Store/Actions/seriesActions';
import MonitoringOptionsModalContent from './MonitoringOptionsModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.series,
(seriesState) => {
const {
isSaving,
saveError
} = seriesState;
return {
isSaving,
saveError
};
}
);
}
const mapDispatchToProps = {
dispatchUpdateMonitoringOptions: updateSeriesMonitor
};
class MonitoringOptionsModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose(true);
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ name, value });
}
onSavePress = ({ monitor }) => {
this.props.dispatchUpdateMonitoringOptions({
id: this.props.seriesId,
monitor
});
}
//
// Render
render() {
return (
<MonitoringOptionsModalContent
{...this.props}
onInputChange={this.onInputChange}
onSavePress={this.onSavePress}
/>
);
}
}
MonitoringOptionsModalContentConnector.propTypes = {
seriesId: PropTypes.number.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
dispatchUpdateMonitoringOptions: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MonitoringOptionsModalContentConnector);

View File

@@ -59,7 +59,7 @@ function UpdateSettings(props) {
type={inputTypes.AUTO_COMPLETE}
name="branch"
helpText={usingExternalUpdateMechanism ? 'Branch used by external update mechanism' : 'Branch to use to update Sonarr'}
helpLink="https://github.com/Sonarr/Sonarr/wiki/Release-Branches"
helpLink="https://wiki.servarr.com/Sonarr_Settings#Updates"
{...branch}
values={branchValues}
onChange={onInputChange}
@@ -97,7 +97,7 @@ function UpdateSettings(props) {
name="updateMechanism"
values={updateOptions}
helpText="Use Sonarr's built-in updater or a script"
helpLink="https://github.com/Sonarr/Sonarr/wiki/Updating"
helpLink="https://wiki.servarr.com/Sonarr_Settings#Updates"
onChange={onInputChange}
{...updateMechanism}
/>

View File

@@ -89,7 +89,7 @@ function IndexerOptions(props) {
unit="minutes"
helpText="Interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)"
helpTextWarning="This will apply to all indexers, please follow the rules set forth by them"
helpLink="https://github.com/Sonarr/Sonarr/wiki/RSS-Sync"
helpLink="https://wiki.servarr.com/Sonarr_FAQ#How_does_Sonarr_find_episodes"
onChange={onInputChange}
{...settings.rssSyncInterval}
/>

View File

@@ -238,7 +238,7 @@ class MediaManagement extends Component {
legend="File Management"
>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>Ignore Deleted Episodes</FormLabel>
<FormLabel>Unmonitor Deleted Episodes</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
@@ -382,17 +382,32 @@ class MediaManagement extends Component {
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>File chmod mode</FormLabel>
<FormLabel>chmod Folder</FormLabel>
<FormInputGroup
type={inputTypes.UMASK}
name="chmodFolder"
helpText="Octal, applied during import/rename to media folders and files (without execute bits)"
helpTextWarning="This only works if the user running sonarr is the owner of the file. It's better to ensure the download client sets the permissions properly."
onChange={onInputChange}
{...settings.chmodFolder}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>chown Group</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="fileChmod"
helpTexts={[
'Octal, applied to media files when imported/renamed by Sonarr',
'The same mode is applied to series/season folders with the execute bit added, e.g., 0644 becomes 0755'
]}
name="chownGroup"
helpText="Group name or gid. Use gid for remote file systems."
helpTextWarning="This only works if the user running sonarr is the owner of the file. It's better to ensure the download client uses the same group as sonarr."
values={fileDateOptions}
onChange={onInputChange}
{...settings.fileChmod}
{...settings.chownGroup}
/>
</FormGroup>
</FieldSet>

View File

@@ -42,11 +42,17 @@ function EditNotificationModalContent(props) {
onDownload,
onUpgrade,
onRename,
onSeriesDelete,
onEpisodeFileDelete,
onEpisodeFileDeleteForUpgrade,
onHealthIssue,
supportsOnGrab,
supportsOnDownload,
supportsOnUpgrade,
supportsOnRename,
supportsOnSeriesDelete,
supportsOnEpisodeFileDelete,
supportsOnEpisodeFileDeleteForUpgrade,
supportsOnHealthIssue,
includeHealthWarnings,
tags,
@@ -150,6 +156,49 @@ function EditNotificationModalContent(props) {
/>
</FormGroup>
<FormGroup>
<FormLabel>On Series Delete</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="onSeriesDelete"
helpText="Be notified when series are deleted"
isDisabled={!supportsOnSeriesDelete.value}
{...onSeriesDelete}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>On Episode File Delete</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="onEpisodeFileDelete"
helpText="Be notified when episode files are deleted"
isDisabled={!supportsOnEpisodeFileDelete.value}
{...onEpisodeFileDelete}
onChange={onInputChange}
/>
</FormGroup>
{
onEpisodeFileDelete.value ?
<FormGroup>
<FormLabel>On Episode File Delete For Upgrade</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="onEpisodeFileDeleteForUpgrade"
helpText="Be notified when episode files are deleted for upgrades"
isDisabled={!supportsOnEpisodeFileDeleteForUpgrade.value}
{...onEpisodeFileDeleteForUpgrade}
onChange={onInputChange}
/>
</FormGroup> :
null
}
<FormGroup>
<FormLabel>On Health Issue</FormLabel>

View File

@@ -58,11 +58,17 @@ class Notification extends Component {
onDownload,
onUpgrade,
onRename,
onSeriesDelete,
onEpisodeFileDelete,
onEpisodeFileDeleteForUpgrade,
onHealthIssue,
supportsOnGrab,
supportsOnDownload,
supportsOnUpgrade,
supportsOnRename,
supportsOnSeriesDelete,
supportsOnEpisodeFileDelete,
supportsOnEpisodeFileDeleteForUpgrade,
supportsOnHealthIssue
} = this.props;
@@ -77,48 +83,78 @@ class Notification extends Component {
</div>
{
supportsOnGrab && onGrab &&
supportsOnGrab && onGrab ?
<Label kind={kinds.SUCCESS}>
On Grab
</Label>
</Label> :
null
}
{
supportsOnDownload && onDownload &&
supportsOnDownload && onDownload ?
<Label kind={kinds.SUCCESS}>
On Import
</Label>
</Label> :
null
}
{
supportsOnUpgrade && onDownload && onUpgrade &&
supportsOnUpgrade && onDownload && onUpgrade ?
<Label kind={kinds.SUCCESS}>
On Upgrade
</Label>
</Label> :
null
}
{
supportsOnRename && onRename &&
supportsOnRename && onRename ?
<Label kind={kinds.SUCCESS}>
On Rename
</Label>
</Label> :
null
}
{
supportsOnHealthIssue && onHealthIssue &&
supportsOnHealthIssue && onHealthIssue ?
<Label kind={kinds.SUCCESS}>
On Health Issue
</Label>
</Label> :
null
}
{
!onGrab && !onDownload && !onRename && !onHealthIssue &&
supportsOnSeriesDelete && onSeriesDelete ?
<Label kind={kinds.SUCCESS}>
On Series Delete
</Label> :
null
}
{
supportsOnEpisodeFileDelete && onEpisodeFileDelete ?
<Label kind={kinds.SUCCESS}>
On Episode File Delete
</Label> :
null
}
{
supportsOnEpisodeFileDeleteForUpgrade && onEpisodeFileDelete && onEpisodeFileDeleteForUpgrade ?
<Label kind={kinds.SUCCESS}>
On Episode File Delete For Upgrade
</Label> :
null
}
{
!onGrab && !onDownload && !onRename && !onHealthIssue && !onSeriesDelete && !onEpisodeFileDelete ?
<Label
kind={kinds.DISABLED}
outline={true}
>
Disabled
</Label>
</Label> :
null
}
<EditNotificationModalConnector
@@ -149,9 +185,15 @@ Notification.propTypes = {
onDownload: PropTypes.bool.isRequired,
onUpgrade: PropTypes.bool.isRequired,
onRename: PropTypes.bool.isRequired,
onSeriesDelete: PropTypes.bool.isRequired,
onEpisodeFileDelete: PropTypes.bool.isRequired,
onEpisodeFileDeleteForUpgrade: PropTypes.bool.isRequired,
onHealthIssue: PropTypes.bool.isRequired,
supportsOnGrab: PropTypes.bool.isRequired,
supportsOnDownload: PropTypes.bool.isRequired,
supportsOnSeriesDelete: PropTypes.bool.isRequired,
supportsOnEpisodeFileDelete: PropTypes.bool.isRequired,
supportsOnEpisodeFileDeleteForUpgrade: PropTypes.bool.isRequired,
supportsOnUpgrade: PropTypes.bool.isRequired,
supportsOnRename: PropTypes.bool.isRequired,
supportsOnHealthIssue: PropTypes.bool.isRequired,

View File

@@ -1,3 +1,7 @@
.horizontalScroll {
overflow-x: auto;
}
.delayProfiles {
user-select: none;
}
@@ -25,3 +29,10 @@
width: $dragHandleWidth;
text-align: center;
}
@media only screen and (max-width: $breakpointSmall) {
.horizontalScroll {
overflow-y: hidden;
width: 100%;
}
}

View File

@@ -1,11 +1,12 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import { icons, scrollDirections } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import Measure from 'Components/Measure';
import PageSectionContent from 'Components/Page/PageSectionContent';
import Scroller from 'Components/Scroller/Scroller';
import DelayProfileDragSource from './DelayProfileDragSource';
import DelayProfileDragPreview from './DelayProfileDragPreview';
import DelayProfile from './DelayProfile';
@@ -71,48 +72,59 @@ class DelayProfiles extends Component {
errorMessage="Unable to load Delay Profiles"
{...otherProps}
>
<div className={styles.delayProfilesHeader}>
<div className={styles.column}>Protocol</div>
<div className={styles.column}>Usenet Delay</div>
<div className={styles.column}>Torrent Delay</div>
<div className={styles.tags}>Tags</div>
</div>
<div className={styles.delayProfiles}>
{
items.map((item, index) => {
return (
<DelayProfileDragSource
key={item.id}
tagList={tagList}
{...item}
{...otherProps}
index={index}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
onConfirmDeleteDelayProfile={onConfirmDeleteDelayProfile}
/>
);
})
<Scroller
className={styles.horizontalScroll}
scrollDirection={
scrollDirections.HORIZONTAL
}
autoFocus={false}
>
<div>
<div className={styles.delayProfilesHeader}>
<div className={styles.column}>Protocol</div>
<div className={styles.column}>Usenet Delay</div>
<div className={styles.column}>Torrent Delay</div>
<div className={styles.tags}>Tags</div>
</div>
<DelayProfileDragPreview
width={width}
/>
</div>
<div className={styles.delayProfiles}>
{
items.map((item, index) => {
return (
<DelayProfileDragSource
key={item.id}
tagList={tagList}
{...item}
{...otherProps}
index={index}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
onConfirmDeleteDelayProfile={onConfirmDeleteDelayProfile}
/>
);
})
}
{
defaultProfile &&
<div>
<DelayProfile
tagList={tagList}
isDragging={false}
onConfirmDeleteDelayProfile={onConfirmDeleteDelayProfile}
{...defaultProfile}
<DelayProfileDragPreview
width={width}
/>
</div>
}
{
defaultProfile ?
<div>
<DelayProfile
tagList={tagList}
isDragging={false}
onConfirmDeleteDelayProfile={onConfirmDeleteDelayProfile}
{...defaultProfile}
/>
</div> :
null
}
</div>
</Scroller>
<div className={styles.addDelayProfile}>
<Link

View File

@@ -62,13 +62,14 @@ function EditReleaseProfileModalContent(props) {
<FormLabel>Must Contain</FormLabel>
<FormInputGroup
{...required}
type={inputTypes.TEXT_TAG}
name="required"
helpText="The release must contain at least one of these terms (case insensitive)"
kind={kinds.SUCCESS}
placeholder="Add new restriction"
delimiters={tagInputDelimiters}
{...required}
canEdit={true}
onChange={onInputChange}
/>
</FormGroup>
@@ -77,13 +78,14 @@ function EditReleaseProfileModalContent(props) {
<FormLabel>Must Not Contain</FormLabel>
<FormInputGroup
{...ignored}
type={inputTypes.TEXT_TAG}
name="ignored"
helpText="The release will be rejected if it contains one or more of terms (case insensitive)"
kind={kinds.DANGER}
placeholder="Add new restriction"
delimiters={tagInputDelimiters}
{...ignored}
canEdit={true}
onChange={onInputChange}
/>
</FormGroup>
@@ -126,6 +128,7 @@ function EditReleaseProfileModalContent(props) {
type={inputTypes.INDEXER_SELECT}
name="indexerId"
helpText="Specify what indexer the profile applies to"
helpTextWarning="Using a specific indexer with preferred words can lead to duplicate releases being grabbed"
{...indexerId}
includeAny={true}
onChange={onInputChange}

View File

@@ -13,6 +13,7 @@ import styles from './QualityDefinition.css';
const MIN = 0;
const MAX = 400;
const MIN_DISTANCE = 1;
const slider = {
min: MIN,
@@ -187,7 +188,7 @@ class QualityDefinition extends Component {
min={slider.min}
max={slider.max}
step={slider.step}
minDistance={10}
minDistance={MIN_DISTANCE * 5}
value={[sliderMinSize, sliderMaxSize]}
withTracks={true}
snapDragDisabled={true}
@@ -243,7 +244,7 @@ class QualityDefinition extends Component {
name={`${id}.min`}
value={minSize || MIN}
min={MIN}
max={maxSize ? maxSize - 10 : MAX - 10}
max={maxSize ? maxSize - MIN_DISTANCE : MAX - MIN_DISTANCE}
step={0.1}
isFloat={true}
onChange={this.onMinSizeChange}
@@ -255,9 +256,9 @@ class QualityDefinition extends Component {
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
name={`${id}.max`}
value={maxSize || MAX}
min={minSize + 10}
min={minSize + MIN_DISTANCE}
max={MAX}
step={0.1}
isFloat={true}

View File

@@ -1,10 +1,12 @@
import $ from 'jquery';
import _ from 'lodash';
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getProviderState from 'Utilities/State/getProviderState';
import { set, updateItem } from '../baseActions';
const abortCurrentRequests = {};
let lastSaveData = null;
export function createCancelSaveProviderHandler(section) {
return function(getState, payload, dispatch) {
@@ -26,25 +28,33 @@ function createSaveProviderHandler(section, url, options = {}) {
} = payload;
const saveData = getProviderState({ id, ...otherPayload }, getState, section);
const requestUrl = id ? `${url}/${id}` : url;
const params = { ...queryParams };
// If the user is re-saving the same provider without changes
// force it to be saved. Only applies to editing existing providers.
if (id && _.isEqual(saveData, lastSaveData)) {
params.forceSave = true;
}
lastSaveData = saveData;
const ajaxOptions = {
url: `${url}?${$.param(queryParams, true)}`,
method: 'POST',
url: `${requestUrl}?${$.param(params, true)}`,
method: id ? 'PUT' : 'POST',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(saveData)
};
if (id) {
ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`;
ajaxOptions.method = 'PUT';
}
const { request, abortRequest } = createAjaxRequest(ajaxOptions);
abortCurrentRequests[section] = abortRequest;
request.done((data) => {
lastSaveData = null;
dispatch(batchActions([
updateItem({ section, ...data }),

View File

@@ -106,6 +106,9 @@ export default {
selectedSchema.onDownload = selectedSchema.supportsOnDownload;
selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
selectedSchema.onRename = selectedSchema.supportsOnRename;
selectedSchema.OnSeriesDelete = selectedSchema.supportsOnSeriesDelete;
selectedSchema.OnEpisodeFileDelete = selectedSchema.supportsOnEpisodeFileDelete;
selectedSchema.OnEpisodeFileDeleteForUpgrade = selectedSchema.supportsOnEpisodeFileDeleteForUpgrade;
return selectedSchema;
});

View File

@@ -1,4 +1,6 @@
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
import { createThunk, handleThunks } from 'Store/thunks';
import { sortDirections } from 'Helpers/Props';
@@ -7,6 +9,7 @@ import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptio
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import { set, updateItem } from './baseActions';
//
// Variables
@@ -24,6 +27,7 @@ export const defaultState = {
sortDirection: sortDirections.DESCENDING,
error: null,
items: [],
isRemoving: false,
columns: [
{
@@ -87,7 +91,8 @@ export const GOTO_LAST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistLastPage';
export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage';
export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort';
export const SET_BLACKLIST_TABLE_OPTION = 'blacklist/setBlacklistTableOption';
export const REMOVE_FROM_BLACKLIST = 'blacklist/removeFromBlacklist';
export const REMOVE_BLACKLIST_ITEM = 'blacklist/removeBlacklistItem';
export const REMOVE_BLACKLIST_ITEMS = 'blacklist/removeBlacklistItems';
export const CLEAR_BLACKLIST = 'blacklist/clearBlacklist';
//
@@ -101,7 +106,8 @@ export const gotoBlacklistLastPage = createThunk(GOTO_LAST_BLACKLIST_PAGE);
export const gotoBlacklistPage = createThunk(GOTO_BLACKLIST_PAGE);
export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT);
export const setBlacklistTableOption = createAction(SET_BLACKLIST_TABLE_OPTION);
export const removeFromBlacklist = createThunk(REMOVE_FROM_BLACKLIST);
export const removeBlacklistItem = createThunk(REMOVE_BLACKLIST_ITEM);
export const removeBlacklistItems = createThunk(REMOVE_BLACKLIST_ITEMS);
export const clearBlacklist = createAction(CLEAR_BLACKLIST);
//
@@ -122,7 +128,53 @@ export const actionHandlers = handleThunks({
[serverSideCollectionHandlers.SORT]: SET_BLACKLIST_SORT
}),
[REMOVE_FROM_BLACKLIST]: createRemoveItemHandler(section, '/blacklist')
[REMOVE_BLACKLIST_ITEM]: createRemoveItemHandler(section, '/blacklist'),
[REMOVE_BLACKLIST_ITEMS]: function(getState, payload, dispatch) {
const {
ids
} = payload;
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section,
id,
isRemoving: true
});
}),
set({ section, isRemoving: true })
]));
const promise = createAjaxRequest({
url: '/blacklist/bulk',
method: 'DELETE',
dataType: 'json',
data: JSON.stringify({ ids })
}).request;
promise.done((data) => {
// Don't use batchActions with thunks
dispatch(fetchBlacklist());
dispatch(set({ section, isRemoving: false }));
});
promise.fail((xhr) => {
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section,
id,
isRemoving: false
});
}),
set({ section, isRemoving: false })
]));
});
}
});
//

View File

@@ -1,5 +1,3 @@
import _ from 'lodash';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { isSameCommand } from 'Utilities/Command';
@@ -9,7 +7,7 @@ import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import { showMessage, hideMessage } from './appActions';
import { updateItem } from './baseActions';
import { updateItem, removeItem } from './baseActions';
//
// Variables
@@ -37,9 +35,9 @@ export const defaultState = {
export const FETCH_COMMANDS = 'commands/fetchCommands';
export const EXECUTE_COMMAND = 'commands/executeCommand';
export const CANCEL_COMMAND = 'commands/cancelCommand';
export const ADD_COMMAND = 'commands/updateCommand';
export const UPDATE_COMMAND = 'commands/finishCommand';
export const FINISH_COMMAND = 'commands/addCommand';
export const ADD_COMMAND = 'commands/addCommand';
export const UPDATE_COMMAND = 'commands/updateCommand';
export const FINISH_COMMAND = 'commands/finishCommand';
export const REMOVE_COMMAND = 'commands/removeCommand';
//
@@ -48,10 +46,10 @@ export const REMOVE_COMMAND = 'commands/removeCommand';
export const fetchCommands = createThunk(FETCH_COMMANDS);
export const executeCommand = createThunk(EXECUTE_COMMAND);
export const cancelCommand = createThunk(CANCEL_COMMAND);
export const addCommand = createThunk(ADD_COMMAND);
export const updateCommand = createThunk(UPDATE_COMMAND);
export const finishCommand = createThunk(FINISH_COMMAND);
export const addCommand = createAction(ADD_COMMAND);
export const removeCommand = createAction(REMOVE_COMMAND);
export const removeCommand = createThunk(REMOVE_COMMAND);
//
// Helpers
@@ -161,6 +159,10 @@ export const actionHandlers = handleThunks({
[CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'),
[ADD_COMMAND]: function(getState, payload, dispatch) {
dispatch(updateItem({ section: 'commands', ...payload }));
},
[UPDATE_COMMAND]: function(getState, payload, dispatch) {
dispatch(updateItem({ section: 'commands', ...payload }));
@@ -183,6 +185,10 @@ export const actionHandlers = handleThunks({
dispatch(updateItem({ section: 'commands', ...payload }));
scheduleRemoveCommand(payload, dispatch);
showCommandMessage(payload, dispatch);
},
[ADD_COMMAND]: function(getState, payload, dispatch) {
dispatch(removeItem({ section: 'commands', ...payload }));
}
});
@@ -190,26 +196,4 @@ export const actionHandlers = handleThunks({
//
// Reducers
export const reducers = createHandleActions({
[ADD_COMMAND]: (state, { payload }) => {
const newState = Object.assign({}, state);
newState.items = [...state.items, payload];
return newState;
},
[REMOVE_COMMAND]: (state, { payload }) => {
const newState = Object.assign({}, state);
newState.items = [...state.items];
const index = _.findIndex(newState.items, { id: payload.id });
if (index > -1) {
newState.items.splice(index, 1);
}
return newState;
}
}, defaultState, section);
export const reducers = createHandleActions({}, defaultState, section);

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