New: Loads of Backend Updates to Clients and Indexers

pull/3564/head
Qstick 5 years ago
parent c48838e5b6
commit 8a9e2dc90d

@ -19,7 +19,8 @@ const cssVarsFiles = [
'../src/Styles/Variables/colors',
'../src/Styles/Variables/dimensions',
'../src/Styles/Variables/fonts',
'../src/Styles/Variables/animations'
'../src/Styles/Variables/animations',
'../src/Styles/Variables/zIndexes'
].map(require.resolve);
const plugins = [

@ -42,13 +42,13 @@ class Queue extends Component {
shouldComponentUpdate(nextProps) {
// Don't update when fetching has completed if items have changed,
// before episodes start fetching or when episodes start fetching.
// before movies start fetching or when movies start fetching.
if (
this.props.isFetching &&
nextProps.isPopulated &&
hasDifferentItems(this.props.items, nextProps.items) &&
nextProps.items.some((e) => e.episodeId)
nextProps.items.some((e) => e.movieId)
) {
return false;
}
@ -139,7 +139,6 @@ class Queue extends Component {
} = this.state;
const isRefreshing = isFetching || isCheckForFinishedDownloadExecuting;
const isAllPopulated = isPopulated && !items.length;
const hasError = error;
const selectedCount = this.getSelectedIds().length;
const disableSelectedActions = selectedCount === 0;
@ -192,7 +191,7 @@ class Queue extends Component {
<PageContentBodyConnector>
{
isRefreshing && !isAllPopulated &&
isRefreshing && !isPopulated &&
<LoadingIndicator />
}
@ -211,7 +210,7 @@ class Queue extends Component {
}
{
isAllPopulated && !hasError && !!items.length &&
isPopulated && !hasError && !!items.length &&
<div>
<Table
columns={columns}
@ -228,7 +227,7 @@ class Queue extends Component {
return (
<QueueRowConnector
key={item.id}
episodeId={item.episodeId}
movieId={item.movieId}
isSelected={selectedState[item.id]}
columns={columns}
{...item}

@ -76,7 +76,7 @@ function QueueDetails(props) {
return (
<Icon
name={icons.DOWNLOADING}
title={`Episode is downloading - ${progress.toFixed(1)}% ${title}`}
title={`Movie is downloading - ${progress.toFixed(1)}% ${title}`}
/>
);
}

@ -14,18 +14,18 @@ class QueueOptions extends Component {
super(props, context);
this.state = {
includeUnknownSeriesItems: props.includeUnknownSeriesItems
includeUnknownMovieItems: props.includeUnknownMovieItems
};
}
componentDidUpdate(prevProps) {
const {
includeUnknownSeriesItems
includeUnknownMovieItems
} = this.props;
if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) {
if (includeUnknownMovieItems !== prevProps.includeUnknownMovieItems) {
this.setState({
includeUnknownSeriesItems
includeUnknownMovieItems
});
}
}
@ -48,19 +48,19 @@ class QueueOptions extends Component {
render() {
const {
includeUnknownSeriesItems
includeUnknownMovieItems
} = this.state;
return (
<Fragment>
<FormGroup>
<FormLabel>Show Unknown Series Items</FormLabel>
<FormLabel>Show Unknown Movie Items</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText="Show items without a series in the queue, this could include removed series, movies or anything else in Sonarr's category"
name="includeUnknownMovieItems"
value={includeUnknownMovieItems}
helpText="Show items without a movie in the queue, this could include removed movie, movies or anything else in Radarr's category"
onChange={this.onOptionChange}
/>
</FormGroup>
@ -70,7 +70,7 @@ class QueueOptions extends Component {
}
QueueOptions.propTypes = {
includeUnknownSeriesItems: PropTypes.bool.isRequired,
includeUnknownMovieItems: PropTypes.bool.isRequired,
onOptionChange: PropTypes.func.isRequired
};

@ -5,7 +5,7 @@ import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
import TableRow from 'Components/Table/TableRow';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
// import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
@ -67,8 +67,7 @@ class QueueRow extends Component {
trackedDownloadStatus,
statusMessages,
errorMessage,
series,
episode,
movie,
quality,
protocol,
indexer,
@ -130,37 +129,28 @@ class QueueRow extends Component {
);
}
if (name === 'series.sortTitle') {
if (name === 'movie.sortTitle') {
return (
<TableRowCell key={name}>
<MovieTitleLink
titleSlug={series.titleSlug}
title={series.title}
titleSlug={movie.titleSlug}
title={movie.title}
/>
</TableRowCell>
);
}
if (name === 'series') {
if (name === 'movie') {
return (
<TableRowCell key={name}>
<MovieTitleLink
titleSlug={series.titleSlug}
title={series.title}
titleSlug={movie.titleSlug}
title={movie.title}
/>
</TableRowCell>
);
}
if (name === 'episode.airDateUtc') {
return (
<RelativeDateCellConnector
key={name}
date={episode.airDateUtc}
/>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
@ -303,8 +293,7 @@ QueueRow.propTypes = {
trackedDownloadStatus: PropTypes.string,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string,
series: PropTypes.object.isRequired,
episode: PropTypes.object.isRequired,
movie: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,

@ -12,14 +12,14 @@ function createMapStateToProps() {
return createSelector(
createMovieSelector(),
createUISettingsSelector(),
(series, uiSettings) => {
(movie, uiSettings) => {
const result = _.pick(uiSettings, [
'showRelativeDates',
'shortDateFormat',
'timeFormat'
]);
result.series = series;
result.movie = movie;
return result;
}
@ -60,7 +60,7 @@ class QueueRowConnector extends Component {
QueueRowConnector.propTypes = {
id: PropTypes.number.isRequired,
episode: PropTypes.object,
movie: PropTypes.object,
grabQueueItem: PropTypes.func.isRequired,
removeQueueItem: PropTypes.func.isRequired
};

@ -116,6 +116,7 @@ function QueueStatusCell(props) {
title={title}
body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle}
position={tooltipPositions.RIGHT}
canFlip={false}
/>
</TableRowCell>
);

@ -1,11 +1,6 @@
.tether {
z-index: 2000;
}
.button {
composes: link from '~Components/Link/Link.css';
position: relative;
display: flex;
align-items: center;
padding: 6px 16px;
@ -35,9 +30,10 @@
}
.contentContainer {
z-index: $popperZIndex;
margin-top: 4px;
padding: 0 8px;
width: 400px;
/* 400px container witdh with 8px padding on each side */
width: 384px;
}
.content {

@ -1,9 +1,10 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import TetherComponent from 'react-tether';
import { Manager, Popper, Reference } from 'react-popper';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Portal from 'Components/Portal';
import FormInputButton from 'Components/Form/FormInputButton';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -12,19 +13,6 @@ import ImportMovieSearchResultConnector from './ImportMovieSearchResultConnector
import ImportMovieTitle from './ImportMovieTitle';
import styles from './ImportMovieSelectMovie.css';
const tetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
}
],
attachment: 'top center',
targetAttachment: 'bottom center'
};
class ImportMovieSelectMovie extends Component {
//
@ -34,8 +22,9 @@ class ImportMovieSelectMovie extends Component {
super(props, context);
this._movieLookupTimeout = null;
this._buttonRef = {};
this._contentRef = {};
this._scheduleUpdate = null;
this._buttonId = getUniqueElememtId();
this._contentId = getUniqueElememtId();
this.state = {
term: props.id,
@ -43,6 +32,12 @@ class ImportMovieSelectMovie extends Component {
};
}
componentDidUpdate() {
if (this._scheduleUpdate) {
this._scheduleUpdate();
}
}
//
// Control
@ -58,8 +53,8 @@ class ImportMovieSelectMovie extends Component {
// Listeners
onWindowClick = (event) => {
const button = ReactDOM.findDOMNode(this._buttonRef.current);
const content = ReactDOM.findDOMNode(this._contentRef.current);
const button = document.getElementById(this._buttonId);
const content = document.getElementById(this._contentId);
if (!button || !content) {
return;
@ -127,150 +122,158 @@ class ImportMovieSelectMovie extends Component {
error.responseJSON.message;
return (
<TetherComponent
classes={{
element: styles.tether
}}
{...tetherOptions}
renderTarget={
(ref) => {
this._buttonRef = ref;
return (
<div ref={ref}>
<Link
className={styles.button}
component="div"
onPress={this.onPress}
>
{
isLookingUpMovie && isQueued && !isPopulated ?
<LoadingIndicator
className={styles.loading}
size={20}
/> :
null
}
{
isPopulated && selectedMovie && isExistingMovie ?
<Manager>
<Reference>
{({ ref }) => (
<div
ref={ref}
id={this._buttonId}
>
<Link
ref={ref}
className={styles.button}
component="div"
onPress={this.onPress}
>
{
isLookingUpMovie && isQueued && !isPopulated ?
<LoadingIndicator
className={styles.loading}
size={20}
/> :
null
}
{
isPopulated && selectedMovie && isExistingMovie ?
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/> :
null
}
{
isPopulated && selectedMovie ?
<ImportMovieTitle
title={selectedMovie.title}
year={selectedMovie.year}
studio={selectedMovie.studio}
isExistingMovie={isExistingMovie}
/> :
null
}
{
isPopulated && !selectedMovie ?
<div className={styles.noMatches}>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/> :
null
}
{
isPopulated && selectedMovie ?
<ImportMovieTitle
title={selectedMovie.title}
year={selectedMovie.year}
network={selectedMovie.network}
isExistingMovie={isExistingMovie}
/> :
null
}
{
isPopulated && !selectedMovie ?
<div>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
/>
No match found!
</div> :
null
}
</div> :
null
}
{
!isFetching && !!error ?
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{
!isFetching && !!error ?
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
Search failed, please try again later.
</div> :
null
}
<div className={styles.dropdownArrowContainer}>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom"
modifiers={{
preventOverflow: {
boundariesElement: 'viewport'
}
}}
>
{({ ref, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={ref}
id={this._contentId}
className={styles.contentContainer}
style={style}
>
{
this.state.isOpen ?
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={this.state.term}
onChange={this.onSearchInputChange}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={this.onRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
</div>
<div className={styles.results}>
{
items.map((item) => {
return (
<ImportMovieSearchResultConnector
key={item.tvdbId}
tmdbId={item.tmdbId}
title={item.title}
year={item.year}
studio={item.studio}
onPress={this.onSeriesSelect}
/>
);
})
}
</div>
</div> :
null
}
<div className={styles.dropdownArrowContainer}>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
</div>
);
}
}
renderElement={
(ref) => {
this._contentRef = ref;
if (!this.state.isOpen) {
return;
}
return (
<div
ref={ref}
className={styles.contentContainer}
>
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={this.state.term}
onChange={this.onSearchInputChange}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={this.onRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
</div>
<div className={styles.results}>
{
items.map((item) => {
return (
<ImportMovieSearchResultConnector
key={item.tmdbId}
tmdbId={item.tmdbId}
title={item.title}
year={item.year}
studio={item.studio}
onPress={this.onMovieSelect}
/>
);
})
}
</div>
</div>
</div>
);
}
}
/>
);
}}
</Popper>
</Portal>
</Manager>
);
}
}

@ -1,9 +1,7 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import jdu from 'jdu';
import styles from './AutoCompleteInput.css';
import AutoSuggestInput from './AutoSuggestInput';
class AutoCompleteInput extends Component {
@ -39,31 +37,6 @@ class AutoCompleteInput extends Component {
});
}
onInputKeyDown = (event) => {
const {
name,
value,
onChange
} = this.props;
const { suggestions } = this.state;
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== this.props.value
) {
event.preventDefault();
if (value) {
onChange({
name,
value: suggestions[0]
});
}
}
}
onInputBlur = () => {
this.setState({ suggestions: [] });
}
@ -88,74 +61,37 @@ class AutoCompleteInput extends Component {
render() {
const {
className,
inputClassName,
name,
value,
placeholder,
hasError,
hasWarning
...otherProps
} = this.props;
const { suggestions } = this.state;
const inputProps = {
className: classNames(
inputClassName,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onBlur: this.onInputBlur
};
const theme = {
container: styles.inputContainer,
containerOpen: styles.inputContainerOpen,
suggestionsContainer: styles.container,
suggestionsList: styles.list,
suggestion: styles.listItem,
suggestionHighlighted: styles.highlighted
};
return (
<div className={className}>
<Autosuggest
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
</div>
<AutoSuggestInput
{...otherProps}
name={name}
value={value}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onInputBlur={this.onInputBlur}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
);
}
}
AutoCompleteInput.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.string).isRequired,
placeholder: PropTypes.string,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
onChange: PropTypes.func.isRequired
};
AutoCompleteInput.defaultProps = {
className: styles.inputWrapper,
inputClassName: styles.input,
value: ''
};

@ -10,25 +10,20 @@
composes: hasWarning from '~Components/Form/Input.css';
}
.inputWrapper {
display: flex;
}
.inputContainer {
position: relative;
flex-grow: 1;
}
.container {
.suggestionsContainer {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.inputContainerOpen {
.container {
position: absolute;
z-index: 1;
.suggestionsContainerOpen {
z-index: $popperZIndex;
.suggestionsContainer {
overflow-y: auto;
max-height: 200px;
width: 100%;
@ -39,20 +34,16 @@
}
}
.list {
.suggestionsList {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
.listItem {
.suggestion {
padding: 0 16px;
}
.match {
font-weight: bold;
}
.highlighted {
.suggestionHighlighted {
background-color: $menuItemHoverBackgroundColor;
}

@ -0,0 +1,257 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import classNames from 'classnames';
import Portal from 'Components/Portal';
import styles from './AutoSuggestInput.css';
class AutoSuggestInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scheduleUpdate = null;
}
componentDidUpdate(prevProps) {
if (
this._scheduleUpdate &&
prevProps.suggestions !== this.props.suggestions
) {
this._scheduleUpdate();
}
}
//
// Control
renderInputComponent = (inputProps) => {
const { renderInputComponent } = this.props;
return (
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input
{...inputProps}
/>
</div>
);
}}
</Reference>
);
}
renderSuggestionsContainer = ({ containerProps, children }) => {
return (
<Portal>
<Popper
placement='bottom-start'
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
flip: {
padding: this.props.minHeight
}
}}
>
{({ ref: popperRef, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={children ? styles.suggestionsContainerOpen : undefined}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
}
//
// Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom,
width
} = data.offsets.reference;
const windowHeight = window.innerHeight;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
data.styles.width = width;
return data;
}
onInputChange = (event, { newValue }) => {
this.props.onChange({
name: this.props.name,
value: newValue
});
}
onInputKeyDown = (event) => {
const {
name,
value,
suggestions,
onChange
} = this.props;
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== this.props.value
) {
event.preventDefault();
if (value) {
onChange({
name,
value: suggestions[0]
});
}
}
}
//
// Render
render() {
const {
forwardedRef,
className,
inputContainerClassName,
name,
value,
placeholder,
suggestions,
hasError,
hasWarning,
getSuggestionValue,
renderSuggestion,
onInputChange,
onInputKeyDown,
onInputFocus,
onInputBlur,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
onSuggestionSelected,
...otherProps
} = this.props;
const inputProps = {
className: classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: onInputChange || this.onInputChange,
onKeyDown: onInputKeyDown || this.onInputKeyDown,
onFocus: onInputFocus,
onBlur: onInputBlur
};
const theme = {
container: inputContainerClassName,
containerOpen: styles.suggestionsContainerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted
};
return (
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={this.renderInputComponent}
renderSuggestionsContainer={this.renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}
}
AutoSuggestInput.propTypes = {
forwardedRef: PropTypes.func,
className: PropTypes.string.isRequired,
inputContainerClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
placeholder: PropTypes.string,
suggestions: PropTypes.array.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
enforceMaxHeight: PropTypes.bool.isRequired,
minHeight: PropTypes.number.isRequired,
maxHeight: PropTypes.number.isRequired,
getSuggestionValue: PropTypes.func.isRequired,
renderInputComponent: PropTypes.func,
renderSuggestion: PropTypes.func.isRequired,
onInputChange: PropTypes.func,
onInputKeyDown: PropTypes.func,
onInputFocus: PropTypes.func,
onInputBlur: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func,
onChange: PropTypes.func.isRequired
};
AutoSuggestInput.defaultProps = {
className: styles.input,
inputContainerClassName: styles.inputContainer,
enforceMaxHeight: true,
minHeight: 50,
maxHeight: 200
};
export default AutoSuggestInput;

@ -2,7 +2,7 @@
display: flex;
}
.inputContainer {
composes: inputContainer from '~./TagInput.css';
.input {
composes: input from '~./TagInput.css';
composes: hasButton from '~Components/Form/Input.css';
}

@ -47,6 +47,7 @@ class DeviceInput extends Component {
render() {
const {
className,
name,
items,
selectedDevices,
hasError,
@ -58,7 +59,8 @@ class DeviceInput extends Component {
return (
<div className={className}>
<TagInput
className={styles.inputContainer}
inputContainerClassName={styles.input}
name={name}
tags={selectedDevices}
tagList={items}
allowNew={true}

@ -1,7 +1,3 @@
.tether {
z-index: 2000;
}
.enhancedSelect {
composes: input from '~Components/Form/Input.css';
composes: link from '~Components/Link/Link.css';
@ -44,10 +40,13 @@
}
.optionsContainer {
z-index: $popperZIndex;
width: auto;
}
.options {
composes: scroller from '~Components/Scroller/Scroller.css';
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;

@ -1,13 +1,14 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import TetherComponent from 'react-tether';
import { Manager, Popper, Reference } from 'react-popper';
import classNames from 'classnames';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import isMobileUtil from 'Utilities/isMobile';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import { icons, scrollDirections } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Portal from 'Components/Portal';
import Link from 'Components/Link/Link';
import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal';
@ -17,19 +18,6 @@ import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import styles from './EnhancedSelectInput.css';
const tetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
}
],
attachment: 'top left',
targetAttachment: 'bottom left'
};
function isArrowKey(keyCode) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@ -87,8 +75,9 @@ class EnhancedSelectInput extends Component {
constructor(props, context) {
super(props, context);
this._buttonRef = {};
this._optionsRef = {};
this._scheduleUpdate = null;
this._buttonId = getUniqueElememtId();
this._optionsId = getUniqueElememtId();
this.state = {
isOpen: false,
@ -99,6 +88,10 @@ class EnhancedSelectInput extends Component {
}
componentDidUpdate(prevProps) {
if (this._scheduleUpdate) {
this._scheduleUpdate();
}
if (prevProps.value !== this.props.value) {
this.setState({
selectedIndex: getSelectedIndex(this.props)
@ -120,9 +113,26 @@ class EnhancedSelectInput extends Component {
//
// Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom
} = data.offsets.reference;
const windowHeight = window.innerHeight;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
return data;
}
onWindowClick = (event) => {
const button = ReactDOM.findDOMNode(this._buttonRef.current);
const options = ReactDOM.findDOMNode(this._optionsRef.current);
const button = document.getElementById(this._buttonId);
const options = document.getElementById(this._optionsId);
if (!button || this.state.isMobile) {
return;
@ -266,96 +276,110 @@ class EnhancedSelectInput extends Component {
return (
<div>
<TetherComponent
classes={{
element: styles.tether
}}
{...tetherOptions}
renderTarget={
(ref) => {
this._buttonRef = ref;
return (
<Manager>
<Reference>
{({ ref }) => (
<div
ref={ref}
id={this._buttonId}
>
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<div ref={ref}>
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled}
>
{selectedOption ? selectedOption.value : null}
</SelectedValueComponent>
<div
className={isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer
}
>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
</div>
{selectedOption ? selectedOption.value : null}
</SelectedValueComponent>
<div
className={isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer
}
>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
</Measure>
);
}
}
renderElement={
(ref) => {
this._optionsRef = ref;
if (!isOpen || isMobile) {
return;
}
return (
<div
ref={ref}
className={styles.optionsContainer}
style={{
minWidth: width
}}
>
<div className={styles.options}>
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
}
}}
>
{({ ref, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={ref}
id={this._optionsId}
className={styles.optionsContainer}
style={{
...style,
minWidth: width
}}
>
{
values.map((v, index) => {
return (
<OptionComponent
key={v.key}
id={v.key}
isSelected={index === selectedIndex}
{...v}
isMobile={false}
onSelect={this.onSelect}
>
{v.value}
</OptionComponent>
);
})
isOpen && !isMobile ?
<Scroller
className={styles.options}
style={{
maxHeight: style.maxHeight
}}
>
{
values.map((v, index) => {
return (
<OptionComponent
key={v.key}
id={v.key}
isSelected={index === selectedIndex}
{...v}
isMobile={false}
onSelect={this.onSelect}
>
{v.value}
</OptionComponent>
);
})
}
</Scroller> :
null
}
</div>
</div>
);
}
}
/>
);
}
}
</Popper>
</Portal>
</Manager>
{
isMobile &&

@ -1,66 +1,16 @@
.path {
composes: input from '~Components/Form/Input.css';
}
.hasError {
composes: hasError from '~Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from '~Components/Form/Input.css';
}
.hasFileBrowser {
composes: input from '~./AutoSuggestInput.css';
composes: hasButton from '~Components/Form/Input.css';
}
.pathInputWrapper {
.inputWrapper {
display: flex;
}
.pathInputContainer {
position: relative;
flex-grow: 1;
}
.pathContainer {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.pathInputContainerOpen {
.pathContainer {
position: absolute;
z-index: 1;
overflow-y: auto;
max-height: 200px;
width: 100%;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
}
.pathList {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
.pathListItem {
padding: 0 16px;
}
.pathMatch {
font-weight: bold;
}
.pathHighlighted {
background-color: $menuItemHoverBackgroundColor;
}
.fileBrowserButton {
composes: button from '~./FormInputButton.css';

@ -1,10 +1,9 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import AutoSuggestInput from './AutoSuggestInput';
import FormInputButton from './FormInputButton';
import styles from './PathInput.css';
@ -16,6 +15,8 @@ class PathInput extends Component {
constructor(props, context) {
super(props, context);
this._node = document.getElementById('portal-root');
this.state = {
isFileBrowserModalOpen: false
};
@ -106,56 +107,30 @@ class PathInput extends Component {
render() {
const {
className,
inputClassName,
name,
value,
placeholder,
paths,
includeFiles,
hasError,
hasWarning,
hasFileBrowser,
onChange
onChange,
...otherProps
} = this.props;
const inputProps = {
className: classNames(
inputClassName,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
hasFileBrowser && styles.hasFileBrowser
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onBlur: this.onInputBlur
};
const theme = {
container: styles.pathInputContainer,
containerOpen: styles.pathInputContainerOpen,
suggestionsContainer: styles.pathContainer,
suggestionsList: styles.pathList,
suggestion: styles.pathListItem,
suggestionHighlighted: styles.pathHighlighted
};
return (
<div className={className}>
<Autosuggest
id={name}
inputProps={inputProps}
theme={theme}
<AutoSuggestInput
{...otherProps}
className={hasFileBrowser ? styles.hasFileBrowser : undefined}
name={name}
value={value}
suggestions={paths}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onInputKeyDown={this.onInputKeyDown}
onInputBlur={this.onInputBlur}
onSuggestionSelected={this.onSuggestionSelected}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onChange={onChange}
/>
{
@ -185,14 +160,10 @@ class PathInput extends Component {
PathInput.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string,
placeholder: PropTypes.string,
paths: PropTypes.array.isRequired,
includeFiles: PropTypes.bool.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
hasFileBrowser: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onFetchPaths: PropTypes.func.isRequired,
@ -200,8 +171,7 @@ PathInput.propTypes = {
};
PathInput.defaultProps = {
className: styles.pathInputWrapper,
inputClassName: styles.path,
className: styles.inputWrapper,
value: '',
hasFileBrowser: true
};

@ -1,5 +1,5 @@
.inputContainer {
composes: input from '~Components/Form/Input.css';
.input {
composes: input from '~./AutoSuggestInput.css';
position: relative;
padding: 0;
@ -13,20 +13,7 @@
}
}
.hasError {
composes: hasError from '~Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from '~Components/Form/Input.css';
}
.tags {
flex: 0 0 auto;
max-width: 100%;
}
.input {
.internalInput {
flex: 1 1 0%;
margin-left: 3px;
min-width: 20%;
@ -35,44 +22,3 @@
height: 21px;
border: none;
}
.suggestionsContainer {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.containerOpen {
.suggestionsContainer {
position: absolute;
right: -1px;
left: -1px;
z-index: 1;
overflow-y: auto;
margin-top: 1px;
max-height: 110px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
}
.suggestionsList {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
.suggestion {
padding: 0 16px;
cursor: default;
&:hover {
background-color: $menuItemHoverBackgroundColor;
}
}
.suggestionHighlighted {
background-color: $menuItemHoverBackgroundColor;
}

@ -1,17 +1,17 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import { kinds } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import AutoSuggestInput from './AutoSuggestInput';
import TagInputInput from './TagInputInput';
import TagInputTag from './TagInputTag';
import styles from './TagInput.css';
function getTag(value, selectedIndex, suggestions, allowNew) {
if (selectedIndex == null && value) {
const existingTag = _.find(suggestions, { name: value });
const existingTag = suggestions.find((suggestion) => suggestion.name === value);
if (existingTag) {
return existingTag;
@ -184,7 +184,7 @@ class TagInput extends Component {
//
// Render
renderInputComponent = (inputProps) => {
renderInputComponent = (inputProps, forwardedRef) => {
const {
tags,
kind,
@ -194,6 +194,7 @@ class TagInput extends Component {
return (
<TagInputInput
forwardedRef={forwardedRef}
tags={tags}
kind={kind}
inputProps={inputProps}
@ -208,10 +209,8 @@ class TagInput extends Component {
render() {
const {
className,
inputClassName,
placeholder,
hasError,
hasWarning
inputContainerClassName,
...otherProps
} = this.props;
const {
@ -220,48 +219,30 @@ class TagInput extends Component {
isFocused
} = this.state;
const inputProps = {
className: inputClassName,
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onFocus: this.onInputFocus,
onBlur: this.onInputBlur
};
const theme = {
container: classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
containerOpen: styles.containerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted
};
return (
<Autosuggest
ref={this._setAutosuggestRef}
id={name}
inputProps={inputProps}
theme={theme}
<AutoSuggestInput
{...otherProps}
forwardedRef={this._setAutosuggestRef}
className={styles.internalInput}
inputContainerClassName={classNames(
inputContainerClassName,
isFocused && styles.isFocused,
)}
value={value}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
shouldRenderSuggestions={this.shouldRenderSuggestions}
focusInputOnSuggestionClick={false}
renderSuggestion={this.renderSuggestion}
renderInputComponent={this.renderInputComponent}
onInputChange={this.onInputChange}
onInputKeyDown={this.onInputKeyDown}
onInputFocus={this.onInputFocus}
onInputBlur={this.onInputBlur}
onSuggestionSelected={this.onSuggestionSelected}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onChange={this.onInputChange}
/>
);
}
@ -269,7 +250,7 @@ class TagInput extends Component {
TagInput.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
inputContainerClassName: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
allowNew: PropTypes.bool.isRequired,
@ -285,8 +266,8 @@ TagInput.propTypes = {
};
TagInput.defaultProps = {
className: styles.inputContainer,
inputClassName: styles.input,
className: styles.internalInput,
inputContainerClassName: styles.input,
allowNew: true,
kind: kinds.INFO,
placeholder: '',

@ -1,4 +1,9 @@
.inputContainer {
position: absolute;
top: -1px;
right: -1px;
bottom: -1px;
left: -1px;
display: flex;
flex-wrap: wrap;
padding: 6px 16px;

@ -23,6 +23,7 @@ class TagInputInput extends Component {
render() {
const {
forwardedRef,
className,
tags,
inputProps,
@ -33,6 +34,7 @@ class TagInputInput extends Component {
return (
<div
ref={forwardedRef}
className={className}
component="div"
onMouseDown={this.onMouseDown}
@ -59,6 +61,7 @@ class TagInputInput extends Component {
}
TagInputInput.propTypes = {
forwardedRef: PropTypes.func,
className: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
inputProps: PropTypes.object.isRequired,

@ -71,6 +71,7 @@
.success {
border-color: $successColor;
background-color: $successColor;
color: #eee;
&.outline {
color: $successColor;
@ -101,7 +102,7 @@
.large {
padding: 3px 7px;
font-weight: bold;
font-size: 14px;
font-size: $defaultFontSize;
}
/** Outline **/

@ -1,7 +1,3 @@
.tether {
z-index: 2000;
}
.menu {
position: relative;
}

@ -1,32 +1,31 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import TetherComponent from 'react-tether';
import { Manager, Popper, Reference } from 'react-popper';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import { align } from 'Helpers/Props';
import Portal from 'Components/Portal';
import styles from './Menu.css';
const baseTetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
const sharedPopperOptions = {
modifiers: {
preventOverflow: {
padding: 0
},
flip: {
padding: 0
}
]
}
};
const tetherOptions = {
const popperOptions = {
[align.RIGHT]: {
...baseTetherOptions,
attachment: 'top right',
targetAttachment: 'bottom right'
...sharedPopperOptions,
placement: 'bottom-end'
},
[align.LEFT]: {
...baseTetherOptions,
attachment: 'top left',
targetAttachment: 'bottom left'
...sharedPopperOptions,
placement: 'bottom-start'
}
};
@ -38,8 +37,8 @@ class Menu extends Component {
constructor(props, context) {
super(props, context);
this._menuRef = {};
this._menuContentRef = {};
this._scheduleUpdate = null;
this._menuButtonId = getUniqueElememtId();
this.state = {
isMenuOpen: false,
@ -51,6 +50,12 @@ class Menu extends Component {
this.setMaxHeight();
}
componentDidUpdate() {
if (this._scheduleUpdate) {
this._scheduleUpdate();
}
}
componentWillUnmount() {
this._removeListener();
}
@ -63,13 +68,13 @@ class Menu extends Component {
return;
}
const menu = ReactDOM.findDOMNode(this._menuRef.current);
const menuButton = document.getElementById(this._menuButtonId);
if (!menu) {
if (!menuButton) {
return;
}
const { bottom } = menu.getBoundingClientRect();
const { bottom } = menuButton.getBoundingClientRect();
const maxHeight = window.innerHeight - bottom;
return maxHeight;
@ -106,14 +111,13 @@ class Menu extends Component {
// Listeners
onWindowClick = (event) => {
const menu = ReactDOM.findDOMNode(this._menuRef.current);
const menuContent = ReactDOM.findDOMNode(this._menuContentRef.current);
const menuButton = document.getElementById(this._menuButtonId);
if (!menu || !menuContent) {
if (!menuButton) {
return;
}
if ((!menu.contains(event.target) || menuContent.contains(event.target)) && this.state.isMenuOpen) {
if (!menuButton.contains(event.target) && this.state.isMenuOpen) {
this.setState({ isMenuOpen: false });
this._removeListener();
}
@ -124,17 +128,9 @@ class Menu extends Component {
}
onWindowScroll = (event) => {
if (!this._menuContentRef.current) {
return;
}
const menuContent = ReactDOM.findDOMNode(this._menuContentRef.current);
if (menuContent && menuContent.contains(event.target)) {
return;
if (this.state.isMenuOpen) {
this.setMaxHeight();
}
this.setMaxHeight();
}
onMenuButtonPress = () => {
@ -176,45 +172,39 @@ class Menu extends Component {
);
return (
<TetherComponent
classes={{
element: styles.tether
}}
{...tetherOptions[alignMenu]}
renderTarget={
(ref) => {
this._menuRef = ref;
return (
<div
ref={ref}
className={className}
>
{button}
</div>
);
}
}
renderElement={
(ref) => {
this._menuContentRef = ref;
if (!isMenuOpen) {
return null;
}
return React.cloneElement(
childrenArray[1],
{
ref,
alignMenu,
maxHeight,
isOpen: isMenuOpen
}
);
}
}
/>
<Manager>
<Reference>
{({ ref }) => (
<div
ref={ref}
id={this._menuButtonId}
className={className}
>
{button}
</div>
)}
</Reference>
<Portal>
<Popper {...popperOptions[alignMenu]}>
{({ ref, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return React.cloneElement(
childrenArray[1],
{
forwardedRef: ref,
style: {
...style,
maxHeight
},
isOpen: isMenuOpen
}
);
}}
</Popper>
</Portal>
</Manager>
);
}
}

@ -1,4 +1,5 @@
.menuContent {
z-index: $popperZIndex;
display: flex;
flex-direction: column;
background-color: $toolbarMenuItemBackgroundColor;

@ -10,30 +10,37 @@ class MenuContent extends Component {
render() {
const {
forwardedRef,
className,
children,
maxHeight
style,
isOpen
} = this.props;
return (
<div
ref={forwardedRef}
className={className}
style={{
maxHeight: maxHeight ? `${maxHeight}px` : undefined
}}
style={style}
>
<Scroller className={styles.scroller}>
{children}
</Scroller>
{
isOpen ?
<Scroller className={styles.scroller}>
{children}
</Scroller> :
null
}
</div>
);
}
}
MenuContent.propTypes = {
forwardedRef: PropTypes.func,
className: PropTypes.string,
children: PropTypes.node.isRequired,
maxHeight: PropTypes.number
style: PropTypes.object,
isOpen: PropTypes.bool
};
MenuContent.defaultProps = {

@ -1,5 +1,6 @@
.separator {
overflow: hidden;
min-height: 1px;
height: 1px;
background-color: $themeDarkColor;
}

@ -1,7 +1,7 @@
.modalContainer {
position: absolute;
top: 0;
z-index: 1000;
z-index: $modalZIndex;
width: 100%;
height: 100%;
}

@ -28,7 +28,7 @@ class Modal extends Component {
constructor(props, context) {
super(props, context);
this._node = document.getElementById('modal-root');
this._node = document.getElementById('portal-root');
this._backgroundRef = null;
this._modalId = getUniqueElememtId();
}

@ -10,13 +10,13 @@
.logoContainer {
display: flex;
justify-content: center;
align-items: center;
flex: 0 0 $sidebarWidth;
padding-left: 20px;
}
.logoFull {
width: 144px;
height: 48px;
.logoLink {
line-height: 0;
}
.logo {

@ -45,17 +45,19 @@ class PageHeader extends Component {
render() {
const {
onSidebarToggle,
isSmallScreen
onSidebarToggle
} = this.props;
return (
<div className={styles.header}>
<div className={styles.logoContainer}>
<Link to={`${window.Radarr.urlBase}/`}>
<Link
className={styles.logoLink}
to={`${window.Radarr.urlBase}/`}
>
<img
className={isSmallScreen ? styles.logo : styles.logoFull}
src={isSmallScreen ? `${window.Radarr.urlBase}/Content/Images/logo.png` : `${window.Radarr.urlBase}/Content/Images/logo-full.png`}
className={styles.logo}
src={`${window.Radarr.urlBase}/Content/Images/logo.svg`}
/>
</Link>
</div>
@ -93,7 +95,6 @@ class PageHeader extends Component {
PageHeader.propTypes = {
onSidebarToggle: PropTypes.func.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
bindShortcut: PropTypes.func.isRequired
};

@ -86,7 +86,6 @@ class Page extends Component {
<PageHeader
onSidebarToggle={onSidebarToggle}
isSmallScreen={isSmallScreen}
/>
<div className={styles.main}>

@ -0,0 +1,18 @@
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
function Portal(props) {
const { children, target } = props;
return ReactDOM.createPortal(children, target);
}
Portal.propTypes = {
children: PropTypes.node.isRequired,
target: PropTypes.object.isRequired
};
Portal.defaultProps = {
target: document.getElementById('portal-root')
};
export default Portal;

@ -47,6 +47,10 @@
.danger {
background-color: $dangerColor;
&:global(.colorImpaired) {
background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px);
}
}
.success {
@ -59,6 +63,10 @@
.warning {
background-color: $warningColor;
&:global(.colorImpaired) {
background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px);
}
}
.info {

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import { kinds, sizes } from 'Helpers/Props';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import styles from './ProgressBar.css';
function ProgressBar(props) {
@ -23,55 +24,65 @@ function ProgressBar(props) {
const actualWidth = width ? `${width}px` : '100%';
return (
<div
className={classNames(
containerClassName,
styles[size]
)}
title={title}
style={{ width: actualWidth }}
>
{
showText && !!width &&
<ColorImpairedConsumer>
{(enableColorImpairedMode) => {
return (
<div
className={styles.backTextContainer}
className={classNames(
containerClassName,
styles[size]
)}
title={title}
style={{ width: actualWidth }}
>
<div className={styles.backText}>
<div>
{progressText}
</div>
</div>
</div>
}
{
showText && width ?
<div
className={styles.backTextContainer}
style={{ width: actualWidth }}
>
<div className={styles.backText}>
<div>
{progressText}
</div>
</div>
</div> :
null
}
<div
className={classNames(
className,
styles[kind]
)}
aria-valuenow={progress}
aria-valuemin="0"
aria-valuemax="100"
style={{ width: progressPercent }}
/>
{
showText &&
<div
className={styles.frontTextContainer}
style={{ width: progressPercent }}
>
<div
className={styles.frontText}
style={{ width: actualWidth }}
>
<div>
{progressText}
</div>
<div
className={classNames(
className,
styles[kind],
enableColorImpairedMode && 'colorImpaired'
)}
aria-valuenow={progress}
aria-valuemin="0"
aria-valuemax="100"
style={{ width: progressPercent }}
/>
{
showText ?
<div
className={styles.frontTextContainer}
style={{ width: progressPercent }}
>
<div
className={styles.frontText}
style={{ width: actualWidth }}
>
<div>
{progressText}
</div>
</div>
</div> :
null
}
</div>
</div>
}
</div>
);
}}
</ColorImpairedConsumer>
);
}

@ -223,10 +223,6 @@ class SignalRConnector extends Component {
this.props.dispatchUpdate({ section: 'queue.status', data: body.resource });
}
handleRootfolder = () => {
this.props.dispatchFetchRootFolders();
}
handleVersion = (body) => {
const version = body.Version;
@ -237,6 +233,10 @@ class SignalRConnector extends Component {
// No-op for now, we may want this later
}
handleRootfolder = () => {
this.props.dispatchFetchRootFolders();
}
handleTag = (body) => {
if (body.action === 'sync') {
this.props.dispatchFetchTags();

@ -1,97 +1,3 @@
.tether {
z-index: 2000;
}
.popoverContainer {
margin: 10px 15px;
}
.popover {
position: relative;
background-color: $white;
box-shadow: 0 5px 10px $popoverShadowColor;
}
.arrow,
.arrow::after {
position: absolute;
display: block;
width: 0;
height: 0;
border-width: 11px;
border-style: solid;
border-color: transparent;
}
.arrow::after {
border-width: 10px;
content: '';
}
.top {
bottom: -11px;
left: 50%;
margin-left: -11px;
border-top-color: $popoverArrowBorderColor;
border-bottom-width: 0;
&::after {
bottom: 1px;
margin-left: -10px;
border-top-color: $white;
border-bottom-width: 0;
content: ' ';
}
}
.right {
top: 50%;
left: -11px;
margin-top: -11px;
border-right-color: $popoverArrowBorderColor;
border-left-width: 0;
&::after {
bottom: -10px;
left: 1px;
border-right-color: $white;
border-left-width: 0;
content: ' ';
}
}
.bottom {
top: -11px;
left: 50%;
margin-left: -11px;
border-top-width: 0;
border-bottom-color: $popoverArrowBorderColor;
&::after {
top: 1px;
margin-left: -10px;
border-top-width: 0;
border-bottom-color: $white;
content: ' ';
}
}
.left {
top: 50%;
right: -11px;
margin-top: -11px;
border-right-width: 0;
border-left-color: $popoverArrowBorderColor;
&::after {
right: 1px;
bottom: -10px;
border-right-width: 0;
border-left-color: $white;
content: ' ';
}
}
.title {
padding: 10px 20px;
border-bottom: 1px solid $popoverTitleBorderColor;
@ -103,3 +9,7 @@
overflow: auto;
padding: 10px;
}
.tooltipBody {
padding: 0;
}

@ -1,171 +1,37 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TetherComponent from 'react-tether';
import classNames from 'classnames';
import isMobileUtil from 'Utilities/isMobile';
import { tooltipPositions } from 'Helpers/Props';
import React from 'react';
import Tooltip from './Tooltip';
import styles from './Popover.css';
const baseTetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
}
]
};
const tetherOptions = {
[tooltipPositions.TOP]: {
...baseTetherOptions,
attachment: 'bottom center',
targetAttachment: 'top center'
},
[tooltipPositions.RIGHT]: {
...baseTetherOptions,
attachment: 'middle left',
targetAttachment: 'middle right'
},
[tooltipPositions.BOTTOM]: {
...baseTetherOptions,
attachment: 'top center',
targetAttachment: 'bottom center'
},
[tooltipPositions.LEFT]: {
...baseTetherOptions,
attachment: 'middle right',
targetAttachment: 'middle left'
}
};
class Popover extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isOpen: false
};
this._closeTimeout = null;
}
componentWillUnmount() {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
}
//
// Listeners
onClick = () => {
if (isMobileUtil()) {
this.setState({ isOpen: !this.state.isOpen });
}
}
onMouseEnter = () => {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
this.setState({ isOpen: true });
}
onMouseLeave = () => {
this._closeTimeout = setTimeout(() => {
this.setState({ isOpen: false });
}, 100);
}
//
// Render
render() {
const {
className,
anchor,
title,
body,
position
} = this.props;
return (
<TetherComponent
classes={{
element: styles.tether
}}
{...tetherOptions[position]}
renderTarget={
(ref) => (
<span
ref={ref}
className={className}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
{anchor}
</span>
)
}
renderElement={
(ref) => {
if (!this.state.isOpen) {
return null;
}
return (
<div
ref={ref}
className={styles.popoverContainer}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
<div className={styles.popover}>
<div
className={classNames(
styles.arrow,
styles[position]
)}
/>
<div className={styles.title}>
{title}
</div>
<div className={styles.body}>
{body}
</div>
</div>
</div>
);
}
}
/>
);
}
function Popover(props) {
const {
title,
body,
...otherProps
} = props;
return (
<Tooltip
{...otherProps}
bodyClassName={styles.tooltipBody}
tooltip={
<div>
<div className={styles.title}>
{title}
</div>
<div className={styles.body}>
{body}
</div>
</div>
}
/>
);
}
Popover.propTypes = {
className: PropTypes.string,
anchor: PropTypes.node.isRequired,
title: PropTypes.string.isRequired,
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
position: PropTypes.oneOf(tooltipPositions.all)
};
Popover.defaultProps = {
position: tooltipPositions.TOP
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired
};
export default Popover;

@ -1,8 +1,5 @@
.tether {
z-index: 2000;
}
.tooltipContainer {
z-index: $popperZIndex;
margin: 10px 15px;
}

@ -1,48 +1,12 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TetherComponent from 'react-tether';
import { Manager, Popper, Reference } from 'react-popper';
import classNames from 'classnames';
import isMobileUtil from 'Utilities/isMobile';
import { kinds, tooltipPositions } from 'Helpers/Props';
import Portal from 'Components/Portal';
import styles from './Tooltip.css';
const baseTetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
}
]
};
const tetherOptions = {
[tooltipPositions.TOP]: {
...baseTetherOptions,
attachment: 'bottom center',
targetAttachment: 'top center'
},
[tooltipPositions.RIGHT]: {
...baseTetherOptions,
attachment: 'middle left',
targetAttachment: 'middle right'
},
[tooltipPositions.BOTTOM]: {
...baseTetherOptions,
attachment: 'top center',
targetAttachment: 'bottom center'
},
[tooltipPositions.LEFT]: {
...baseTetherOptions,
attachment: 'middle right',
targetAttachment: 'middle left'
}
};
class Tooltip extends Component {
//
@ -51,11 +15,18 @@ class Tooltip extends Component {
constructor(props, context) {
super(props, context);
this._scheduleUpdate = null;
this._closeTimeout = null;
this.state = {
isOpen: false
};
}
this._closeTimeout = null;
componentDidUpdate() {
if (this._scheduleUpdate && this.state.isOpen) {
this._scheduleUpdate();
}
}
componentWillUnmount() {
@ -64,9 +35,40 @@ class Tooltip extends Component {
}
}
//
// Control
computeMaxSize = (data) => {
const {
top,
right,
bottom,
left
} = data.offsets.reference;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if ((/^top/).test(data.placement)) {
data.styles.maxHeight = top - 20;
} else if ((/^bottom/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom - 20;
} else if ((/^right/).test(data.placement)) {
data.styles.maxWidth = windowWidth - right - 30;
} else {
data.styles.maxWidth = left - 30;
}
return data;
}
//
// Listeners
onMeasure = ({ width }) => {
this.setState({ width });
}
onClick = () => {
if (isMobileUtil()) {
this.setState({ isOpen: !this.state.isOpen });
@ -93,20 +95,18 @@ class Tooltip extends Component {
render() {
const {
className,
bodyClassName,
anchor,
tooltip,
kind,
position
position,
canFlip
} = this.props;
return (
<TetherComponent
classes={{
element: styles.tether
}}
{...tetherOptions[position]}
renderTarget={
(ref) => (
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
className={className}
@ -116,59 +116,91 @@ class Tooltip extends Component {
>
{anchor}
</span>
)
}
renderElement={
(ref) => {
if (!this.state.isOpen) {
return;
}
return (
<div
ref={ref}
className={styles.tooltipContainer}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
)}
</Reference>
<Portal>
<Popper
placement={position}
// Disable events to improve performance when many tooltips
// are shown (Quality Definitions for example).
eventsEnabled={false}
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.computeMaxSize
},
preventOverflow: {
// Fixes positioning for tooltips in the queue
// and likely others.
escapeWithReference: true
},
flip: {
enabled: canFlip
}
}}
>
{({ ref, style, placement, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
className={classNames(
styles.tooltip,
styles[kind]
)}
ref={ref}
className={styles.tooltipContainer}
style={style}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
<div
className={classNames(
styles.arrow,
styles[kind],
styles[position]
)}
/>
<div className={styles.body}>
{tooltip}
</div>
{
this.state.isOpen ?
<div
className={classNames(
styles.tooltip,
styles[kind]
)}
>
<div
className={classNames(
styles.arrow,
styles[kind],
styles[placement.split('-')[0]]
)}
/>
<div
className={bodyClassName}
>
{tooltip}
</div>
</div> :
null
}
</div>
</div>
);
}
}
/>
);
}}
</Popper>
</Portal>
</Manager>
);
}
}
Tooltip.propTypes = {
className: PropTypes.string,
bodyClassName: PropTypes.string.isRequired,
anchor: PropTypes.node.isRequired,
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
position: PropTypes.oneOf(tooltipPositions.all)
position: PropTypes.oneOf(tooltipPositions.all),
canFlip: PropTypes.bool.isRequired
};
Tooltip.defaultProps = {
bodyClassName: styles.body,
kind: kinds.DEFAULT,
position: tooltipPositions.TOP
position: tooltipPositions.TOP,
canFlip: true
};
export default Tooltip;

@ -24,6 +24,7 @@
.added,
.inCinemas,
.physicalRelease,
.genres {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';

@ -38,7 +38,8 @@ function EditIndexerModalContent(props) {
implementationName,
name,
enableRss,
enableSearch,
enableAutomaticSearch,
enableInteractiveSearch,
supportsRss,
supportsSearch,
fields
@ -63,9 +64,7 @@ function EditIndexerModalContent(props) {
{
!isFetching && !error &&
<Form
{...otherProps}
>
<Form {...otherProps}>
<FormGroup>
<FormLabel>Name</FormLabel>
@ -91,15 +90,29 @@ function EditIndexerModalContent(props) {
</FormGroup>
<FormGroup>
<FormLabel>Enable Search</FormLabel>
<FormLabel>Enable Automatic Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableSearch"
name="enableAutomaticSearch"
helpText={supportsSearch.value ? 'Will be used when automatic searches are performed via the UI or by Radarr' : undefined}
helpTextWarning={supportsSearch.value ? undefined : 'Search is not supported with this indexer'}
isDisabled={!supportsSearch.value}
{...enableSearch}
{...enableAutomaticSearch}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Enable Interactive Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableInteractiveSearch"
helpText={supportsSearch.value ? 'Will be used when interactive search is used' : undefined}
helpTextWarning={supportsSearch.value ? undefined : 'Search is not supported with this indexer'}
isDisabled={!supportsSearch.value}
{...enableInteractiveSearch}
onChange={onInputChange}
/>
</FormGroup>

@ -55,7 +55,8 @@ class Indexer extends Component {
id,
name,
enableRss,
enableSearch,
enableAutomaticSearch,
enableInteractiveSearch,
supportsRss,
supportsSearch
} = this.props;
@ -80,14 +81,21 @@ class Indexer extends Component {
}
{
supportsSearch && enableSearch &&
supportsSearch && enableAutomaticSearch &&
<Label kind={kinds.SUCCESS}>
Search
Automatic Search
</Label>
}
{
!enableRss && !enableSearch &&
supportsSearch && enableInteractiveSearch &&
<Label kind={kinds.SUCCESS}>
Interactive Search
</Label>
}
{
!enableRss && !enableAutomaticSearch && !enableInteractiveSearch &&
<Label
kind={kinds.DISABLED}
outline={true}
@ -122,7 +130,8 @@ Indexer.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
enableRss: PropTypes.bool.isRequired,
enableSearch: PropTypes.bool.isRequired,
enableAutomaticSearch: PropTypes.bool.isRequired,
enableInteractiveSearch: PropTypes.bool.isRequired,
supportsRss: PropTypes.bool.isRequired,
supportsSearch: PropTypes.bool.isRequired,
onConfirmDeleteIndexer: PropTypes.func.isRequired

@ -0,0 +1,33 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
function QualityDefinitionLimits(props) {
const {
bytes,
message
} = props;
if (!bytes) {
return <div>{message}</div>;
}
const thirty = formatBytes(bytes * 30);
const fourtyFive = formatBytes(bytes * 45);
const sixty = formatBytes(bytes * 60);
return (
<div>
<div>30 Minutes: {thirty}</div>
<div>45 Minutes: {fourtyFive}</div>
<div>60 Minutes: {sixty}</div>
</div>
);
}
QualityDefinitionLimits.propTypes = {
bytes: PropTypes.number,
message: PropTypes.string.isRequired
};
export default QualityDefinitionLimits;

@ -98,6 +98,12 @@ export const defaultState = {
isSortable: true,
isVisible: false
},
{
name: 'outputPath',
label: 'Output Path',
isSortable: false,
isVisible: false
},
{
name: 'estimatedCompletionTime',
label: 'Timeleft',

@ -6,12 +6,12 @@ function createProfileInUseSelector(profileProp) {
return createSelector(
(state, { id }) => id,
createAllMoviesSelector(),
(id, series) => {
(id, movies) => {
if (!id) {
return false;
}
return _.some(series, { [profileProp]: id });
return _.some(movies, { [profileProp]: id });
}
);
}

@ -162,7 +162,7 @@ module.exports = {
popoverTitleBackgroundColor: '#f7f7f7',
popoverTitleBorderColor: '#ebebeb',
popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
popoverArrowBorderColor: 'rgba(0, 0, 0, 0.25)',
popoverArrowBorderColor: '#fff',
popoverTitleBackgroundInverseColor: '#3a3f51',
popoverTitleBorderInverseColor: '#4f566f',

@ -0,0 +1,4 @@
module.exports = {
modalZIndex: 1000,
popperZIndex: 2000
};

@ -48,7 +48,7 @@
</head>
<body>
<div id="modal-root"></div>
<div id="portal-root"></div>
<div id="root" class="root"></div>
</body>

@ -66,7 +66,7 @@
"gulp-stripbom": "1.0.4",
"gulp-watch": "5.0.1",
"gulp-wrap": "0.15.0",
"history": "4.9.0",
"history": "4.7.2",
"jdu": "1.0.0",
"jquery": "3.4.0",
"loader-utils": "^1.1.0",
@ -96,11 +96,11 @@
"react-google-recaptcha": "1.0.5",
"react-lazyload": "2.5.0",
"react-measure": "1.4.7",
"react-popper": "1.3.3",
"react-redux": "6.0.1",
"react-router-dom": "4.3.1",
"react-slider": "0.11.2",
"react-tabs": "3.0.0",
"react-tether": "2.0.1",
"react-text-truncate": "0.14.1",
"react-virtualized": "9.21.0",
"redux": "4.0.1",

@ -1,4 +1,4 @@
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Indexers;
namespace NzbDrone.Api.Indexers
{
@ -14,7 +14,7 @@ namespace NzbDrone.Api.Indexers
base.MapToResource(resource, definition);
resource.EnableRss = definition.EnableRss;
resource.EnableSearch = definition.EnableSearch;
resource.EnableSearch = definition.EnableAutomaticSearch || definition.EnableInteractiveSearch;
resource.SupportsRss = definition.SupportsRss;
resource.SupportsSearch = definition.SupportsSearch;
resource.Protocol = definition.Protocol;
@ -25,7 +25,8 @@ namespace NzbDrone.Api.Indexers
base.MapToModel(definition, resource);
definition.EnableRss = resource.EnableRss;
definition.EnableSearch = resource.EnableSearch;
definition.EnableAutomaticSearch = resource.EnableSearch;
definition.EnableInteractiveSearch = resource.EnableSearch;
}
protected override void Validate(IndexerDefinition definition, bool includeWarnings)
@ -34,4 +35,4 @@ namespace NzbDrone.Api.Indexers
base.Validate(definition, includeWarnings);
}
}
}
}

@ -63,7 +63,7 @@ namespace NzbDrone.Api.Indexers
}
try
{
_downloadService.DownloadReport(remoteMovie, false);
_downloadService.DownloadReport(remoteMovie);
}
catch (ReleaseDownloadException ex)
{

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Radarr.Http.REST;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Indexers;

@ -8,6 +8,7 @@ using NzbDrone.Core.Qualities;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Movies.AlternativeTitles;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Languages;
namespace NzbDrone.Api.Movies
{

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser;
using Radarr.Http;

@ -3,7 +3,7 @@ using System.Linq;
using NzbDrone.Common.Extensions;
using NzbDrone.Api.Qualities;
using Radarr.Http.REST;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Qualities;

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Qualities;

@ -106,7 +106,7 @@ namespace NzbDrone.Api.Queue
throw new NotFoundException();
}
_downloadService.DownloadReport(pendingRelease.RemoteMovie, false);
_downloadService.DownloadReport(pendingRelease.RemoteMovie);
return resource.AsResponse();
}

@ -19,7 +19,8 @@ namespace NzbDrone.Api.RootFolders
MappedNetworkDriveValidator mappedNetworkDriveValidator,
StartupFolderValidator startupFolderValidator,
SystemFolderValidator systemFolderValidator,
FolderWritableValidator folderWritableValidator)
FolderWritableValidator folderWritableValidator
)
: base(signalRBroadcaster)
{
_rootFolderService = rootFolderService;
@ -54,7 +55,7 @@ namespace NzbDrone.Api.RootFolders
private List<RootFolderResource> GetRootFolders()
{
return _rootFolderService.AllWithSpace().ToResource();
return _rootFolderService.AllWithUnmappedFolders().ToResource();
}
private void DeleteFolder(int id)

@ -1,4 +1,4 @@
using System;
using System;
using System.ServiceProcess;
using FluentAssertions;
using NUnit.Framework;
@ -36,7 +36,7 @@ namespace NzbDrone.Common.Test
{
if (Subject.ServiceExist(TEMP_SERVICE_NAME))
{
Subject.UnInstall(TEMP_SERVICE_NAME);
Subject.Uninstall(TEMP_SERVICE_NAME);
}
if (Subject.IsServiceRunning(ALWAYS_INSTALLED_SERVICE))
@ -65,7 +65,7 @@ namespace NzbDrone.Common.Test
Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse("Service already installed");
Subject.Install(TEMP_SERVICE_NAME);
Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeTrue();
Subject.UnInstall(TEMP_SERVICE_NAME);
Subject.Uninstall(TEMP_SERVICE_NAME);
Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse();
ExceptionVerification.ExpectedWarns(1);
@ -76,7 +76,7 @@ namespace NzbDrone.Common.Test
[ManualTest]
public void UnInstallService()
{
Subject.UnInstall(ServiceProvider.SERVICE_NAME);
Subject.Uninstall(ServiceProvider.SERVICE_NAME);
Subject.ServiceExist(ServiceProvider.SERVICE_NAME).Should().BeFalse();
}

@ -5,7 +5,6 @@ using ICSharpCode.SharpZipLib.GZip;
using ICSharpCode.SharpZipLib.Tar;
using ICSharpCode.SharpZipLib.Zip;
using NLog;
using NzbDrone.Common.EnvironmentInfo;
namespace NzbDrone.Common
{
@ -32,7 +31,6 @@ namespace NzbDrone.Common
{
ExtractZip(compressedFile, destination);
}
else
{
ExtractTgz(compressedFile, destination);

@ -96,4 +96,4 @@ namespace NzbDrone.Common.Composition
);
}
}
}
}

@ -6,7 +6,6 @@ using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Messaging;
using TinyIoC;
namespace NzbDrone.Common.Composition
{
public abstract class ContainerBuilderBase

@ -21,9 +21,17 @@ namespace NzbDrone.Common
Console.WriteLine();
Console.WriteLine(" Usage: {0} <command> ", Process.GetCurrentProcess().MainModule.ModuleName);
Console.WriteLine(" Commands:");
Console.WriteLine(" /{0} Install the application as a Windows Service ({1}).", StartupContext.INSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
Console.WriteLine(" /{0} Uninstall already installed Windows Service ({1}).", StartupContext.UNINSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
if (OsInfo.IsWindows)
{
Console.WriteLine(" /{0} Install the application as a Windows Service ({1}).", StartupContext.INSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
Console.WriteLine(" /{0} Uninstall already installed Windows Service ({1}).", StartupContext.UNINSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
Console.WriteLine(" /{0} Register URL and open firewall port (allows access from other devices on your network).", StartupContext.REGISTER_URL);
}
Console.WriteLine(" /{0} Don't open Radarr in a browser", StartupContext.NO_BROWSER);
Console.WriteLine(" /{0} Start Radarr terminating any other instances", StartupContext.TERMINATE);
Console.WriteLine(" /{0}=path Path to use as the AppData location (stores database, config, logs, etc)", StartupContext.APPDATA);
Console.WriteLine(" <No Arguments> Run application in console mode.");
}

@ -0,0 +1,29 @@
using System;
using System.IO;
using System.Runtime.Serialization;
namespace NzbDrone.Common.Disk
{
public class DestinationAlreadyExistsException : IOException
{
public DestinationAlreadyExistsException()
{
}
public DestinationAlreadyExistsException(string message) : base(message)
{
}
public DestinationAlreadyExistsException(string message, int hresult) : base(message, hresult)
{
}
public DestinationAlreadyExistsException(string message, Exception innerException) : base(message, innerException)
{
}
protected DestinationAlreadyExistsException(SerializationInfo info, StreamingContext context) : base(info, context)
{
}
}
}

@ -109,12 +109,12 @@ namespace NzbDrone.Common.Disk
switch (stringComparison)
{
case StringComparison.CurrentCulture:
case StringComparison.InvariantCulture:
case StringComparison.Ordinal:
{
return File.Exists(path) && path == path.GetActualCasing();
}
case StringComparison.CurrentCulture:
case StringComparison.InvariantCulture:
case StringComparison.Ordinal:
{
return File.Exists(path) && path == path.GetActualCasing();
}
default:
{
return File.Exists(path);

@ -5,6 +5,7 @@ using System.Threading;
using NLog;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Exceptions;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Common.Disk
@ -55,6 +56,23 @@ namespace NzbDrone.Common.Disk
Ensure.That(sourcePath, () => sourcePath).IsValidPath();
Ensure.That(targetPath, () => targetPath).IsValidPath();
if (mode == TransferMode.Move && !_diskProvider.FolderExists(targetPath))
{
if (verificationMode == DiskTransferVerificationMode.TryTransactional || verificationMode == DiskTransferVerificationMode.VerifyOnly)
{
var sourceMount = _diskProvider.GetMount(sourcePath);
var targetMount = _diskProvider.GetMount(targetPath);
// If we're on the same mount, do a simple folder move.
if (sourceMount != null && targetMount != null && sourceMount.RootDirectory == targetMount.RootDirectory)
{
_logger.Debug("Move Directory [{0}] > [{1}]", sourcePath, targetPath);
_diskProvider.MoveFolder(sourcePath, targetPath);
return mode;
}
}
}
if (!_diskProvider.FolderExists(targetPath))
{
_diskProvider.CreateFolder(targetPath);
@ -64,11 +82,15 @@ namespace NzbDrone.Common.Disk
foreach (var subDir in _diskProvider.GetDirectoryInfos(sourcePath))
{
if (ShouldIgnore(subDir)) continue;
result &= TransferFolder(subDir.FullName, Path.Combine(targetPath, subDir.Name), mode, verificationMode);
}
foreach (var sourceFile in _diskProvider.GetFileInfos(sourcePath))
{
if (ShouldIgnore(sourceFile)) continue;
var destFile = Path.Combine(targetPath, sourceFile.Name);
result &= TransferFile(sourceFile.FullName, destFile, mode, true, verificationMode);
@ -101,11 +123,15 @@ namespace NzbDrone.Common.Disk
foreach (var subDir in targetFolders.Where(v => !sourceFolders.Any(d => d.Name == v.Name)))
{
if (ShouldIgnore(subDir)) continue;
_diskProvider.DeleteFolder(subDir.FullName, true);
}
foreach (var subDir in sourceFolders)
{
if (ShouldIgnore(subDir)) continue;
filesCopied += MirrorFolder(subDir.FullName, Path.Combine(targetPath, subDir.Name));
}
@ -114,11 +140,15 @@ namespace NzbDrone.Common.Disk
foreach (var targetFile in targetFiles.Where(v => !sourceFiles.Any(d => d.Name == v.Name)))
{
if (ShouldIgnore(targetFile)) continue;
_diskProvider.DeleteFile(targetFile.FullName);
}
foreach (var sourceFile in sourceFiles)
{
if (ShouldIgnore(sourceFile)) continue;
var targetFile = Path.Combine(targetPath, sourceFile.Name);
if (CompareFiles(sourceFile.FullName, targetFile))
@ -211,7 +241,7 @@ namespace NzbDrone.Common.Disk
_diskProvider.MoveFile(sourcePath, tempPath, true);
try
{
ClearTargetPath(targetPath, overwrite);
ClearTargetPath(sourcePath, targetPath, overwrite);
_diskProvider.MoveFile(tempPath, targetPath);
@ -241,7 +271,7 @@ namespace NzbDrone.Common.Disk
throw new IOException(string.Format("Destination cannot be a child of the source [{0}] => [{1}]", sourcePath, targetPath));
}
ClearTargetPath(targetPath, overwrite);
ClearTargetPath(sourcePath, targetPath, overwrite);
if (mode.HasFlag(TransferMode.HardLink))
{
@ -318,7 +348,7 @@ namespace NzbDrone.Common.Disk
return TransferMode.None;
}
private void ClearTargetPath(string targetPath, bool overwrite)
private void ClearTargetPath(string sourcePath, string targetPath, bool overwrite)
{
if (_diskProvider.FileExists(targetPath))
{
@ -328,7 +358,7 @@ namespace NzbDrone.Common.Disk
}
else
{
throw new IOException(string.Format("Destination already exists [{0}]", targetPath));
throw new DestinationAlreadyExistsException($"Destination {targetPath} already exists.");
}
}
}
@ -352,7 +382,7 @@ namespace NzbDrone.Common.Disk
}
catch (Exception ex)
{
_logger.Error(ex, string.Format("Failed to properly rollback the file move [{0}] to [{1}], incomplete file may be left in target path.", sourcePath, targetPath));
_logger.Error(ex, "Failed to properly rollback the file move [{0}] to [{1}], incomplete file may be left in target path.", sourcePath, targetPath);
}
}
@ -368,7 +398,7 @@ namespace NzbDrone.Common.Disk
}
catch (Exception ex)
{
_logger.Error(ex, string.Format("Failed to properly rollback the file move [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath));
_logger.Error(ex, "Failed to properly rollback the file move [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath);
}
}
@ -387,7 +417,7 @@ namespace NzbDrone.Common.Disk
}
catch (Exception ex)
{
_logger.Error(ex, string.Format("Failed to properly rollback the file copy [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath));
_logger.Error(ex, "Failed to properly rollback the file copy [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath);
}
}
@ -429,7 +459,7 @@ namespace NzbDrone.Common.Disk
if (i == RetryCount)
{
_logger.Error("Failed to completely transfer [{0}] to [{1}], aborting.", sourcePath, targetPath, i + 1, RetryCount);
_logger.Error("Failed to completely transfer [{0}] to [{1}], aborting.", sourcePath, targetPath);
}
else
{
@ -564,5 +594,27 @@ namespace NzbDrone.Common.Disk
throw;
}
}
private bool ShouldIgnore(DirectoryInfo folder)
{
if (folder.Name.StartsWith(".nfs"))
{
_logger.Trace("Ignoring folder {0}", folder.FullName);
return true;
}
return false;
}
private bool ShouldIgnore(FileInfo file)
{
if (file.Name.StartsWith(".nfs") || file.Name == "debug.log" || file.Name.EndsWith(".socket"))
{
_logger.Trace("Ignoring file {0}", file.FullName);
return true;
}
return false;
}
}
}

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NLog;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
@ -98,15 +97,16 @@ namespace NzbDrone.Common.Disk
{
return d.DriveType != DriveType.Network;
}
return true;
})
.Select(d => new FileSystemModel
{
Type = FileSystemEntityType.Drive,
Name = GetVolumeName(d),
Path = d.RootDirectory,
LastModified = null
})
{
Type = FileSystemEntityType.Drive,
Name = GetVolumeName(d),
Path = d.RootDirectory,
LastModified = null
})
.ToList();
}
@ -118,6 +118,7 @@ namespace NzbDrone.Common.Disk
{
result.Parent = GetParent(path);
result.Directories = GetDirectories(path);
if (includeFiles)
{
result.Files = GetFiles(path);
@ -149,12 +150,12 @@ namespace NzbDrone.Common.Disk
var directories = _diskProvider.GetDirectoryInfos(path)
.OrderBy(d => d.Name)
.Select(d => new FileSystemModel
{
Name = d.Name,
Path = GetDirectoryPath(d.FullName.GetActualCasing()),
LastModified = d.LastWriteTimeUtc,
Type = FileSystemEntityType.Folder
})
{
Name = d.Name,
Path = GetDirectoryPath(d.FullName.GetActualCasing()),
LastModified = d.LastWriteTimeUtc,
Type = FileSystemEntityType.Folder
})
.ToList();
directories.RemoveAll(d => _setToRemove.Contains(d.Name.ToLowerInvariant()));
@ -167,14 +168,14 @@ namespace NzbDrone.Common.Disk
return _diskProvider.GetFileInfos(path)
.OrderBy(d => d.Name)
.Select(d => new FileSystemModel
{
Name = d.Name,
Path = d.FullName.GetActualCasing(),
LastModified = d.LastWriteTimeUtc,
Extension = d.Extension,
Size = d.Length,
Type = FileSystemEntityType.File
})
{
Name = d.Name,
Path = d.FullName.GetActualCasing(),
LastModified = d.LastWriteTimeUtc,
Extension = d.Extension,
Size = d.Length,
Type = FileSystemEntityType.File
})
.ToList();
}
@ -184,6 +185,7 @@ namespace NzbDrone.Common.Disk
{
return mountInfo.Name;
}
return $"{mountInfo.Name} ({mountInfo.VolumeLabel})";
}

@ -0,0 +1,15 @@
using System;
namespace NzbDrone.Common.Disk
{
public static class LongPathSupport
{
public static void Enable()
{
// Mono has an issue with enabling long path support via app.config.
// This works for both mono and .net on Windows.
AppContext.SetSwitch("Switch.System.IO.UseLegacyPathHandling", false);
AppContext.SetSwitch("Switch.System.IO.BlockLongPaths", false);
}
}
}

@ -0,0 +1,15 @@
using NzbDrone.Common.Exceptions;
namespace NzbDrone.Common.Disk
{
public class NotParentException : NzbDroneException
{
public NotParentException(string message, params object[] args) : base(message, args)
{
}
public NotParentException(string message) : base(message)
{
}
}
}

@ -105,7 +105,7 @@ namespace NzbDrone.Common.EnsureThat
{
throw ExceptionFactory.CreateForParamValidation(param.Name, string.Format("value [{0}] is not a valid Windows path. paths must be a full path eg. C:\\Windows", param.Value));
}
throw ExceptionFactory.CreateForParamValidation(param.Name, string.Format("value [{0}] is not a valid *nix path. paths must start with /", param.Value));
}
}

@ -25,9 +25,9 @@ namespace NzbDrone.Common.EnvironmentInfo
private readonly Logger _logger;
public AppFolderFactory(IAppFolderInfo appFolderInfo,
IStartupContext startupContext,
IDiskProvider diskProvider,
IDiskTransferService diskTransferService)
IStartupContext startupContext,
IDiskProvider diskProvider,
IDiskTransferService diskTransferService)
{
_appFolderInfo = appFolderInfo;
_startupContext = startupContext;
@ -43,9 +43,9 @@ namespace NzbDrone.Common.EnvironmentInfo
MigrateAppDataFolder();
_diskProvider.EnsureFolder(_appFolderInfo.AppDataFolder);
}
catch (UnauthorizedAccessException ex)
catch (UnauthorizedAccessException)
{
throw new RadarrStartupException(ex, "Cannot create AppFolder, Access to the path {0} is denied", _appFolderInfo.AppDataFolder);
throw new RadarrStartupException("Cannot create AppFolder, Access to the path {0} is denied", _appFolderInfo.AppDataFolder);
}
@ -112,6 +112,7 @@ namespace NzbDrone.Common.EnvironmentInfo
}
}
private void InitializeMonoApplicationData()
{
if (OsInfo.IsWindows) return;
@ -149,6 +150,7 @@ namespace NzbDrone.Common.EnvironmentInfo
.ToList()
.ForEach(_diskProvider.DeleteFile);
}
private void RemovePidFile()
{
if (OsInfo.IsNotWindows)

@ -17,7 +17,6 @@ namespace NzbDrone.Common.EnvironmentInfo
{
private readonly Environment.SpecialFolder DATA_SPECIAL_FOLDER = Environment.SpecialFolder.CommonApplicationData;
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(AppFolderInfo));
public AppFolderInfo(IStartupContext startupContext)

@ -26,6 +26,7 @@ namespace NzbDrone.Common.EnvironmentInfo
Release = $"{Version}-{Branch}";
}
public static Version Version { get; }
public static String Branch { get; }
public static string Release { get; }
@ -51,4 +52,4 @@ namespace NzbDrone.Common.EnvironmentInfo
}
}
}
}
}

@ -6,4 +6,4 @@ namespace NzbDrone.Common.EnvironmentInfo
string Name { get; }
string FullName { get; }
}
}
}

@ -6,4 +6,4 @@ namespace NzbDrone.Common.EnvironmentInfo
bool Enabled { get; }
OsVersionModel Read();
}
}
}

@ -82,6 +82,9 @@ namespace NzbDrone.Common.EnvironmentInfo
Name = Os.ToString();
FullName = Name;
}
Environment.SetEnvironmentVariable("OS_NAME", Name);
Environment.SetEnvironmentVariable("OS_VERSION", Version);
}
}
@ -98,4 +101,4 @@ namespace NzbDrone.Common.EnvironmentInfo
Linux,
Osx
}
}
}

@ -26,4 +26,4 @@ namespace NzbDrone.Common.EnvironmentInfo
public string FullName { get; }
public string Version { get; }
}
}
}

@ -29,7 +29,7 @@ namespace NzbDrone.Common.EnvironmentInfo
if (entry != null)
{
ExecutingApplication = entry.Location;
IsWindowsTray = entry.ManifestModule.Name == $"{ProcessProvider.RADARR_PROCESS_NAME}.exe";
IsWindowsTray = OsInfo.IsWindows && entry.ManifestModule.Name == $"{ProcessProvider.RADARR_PROCESS_NAME}.exe";
}
}
@ -129,8 +129,8 @@ namespace NzbDrone.Common.EnvironmentInfo
try
{
var currentAssmeblyLocation = typeof(RuntimeInfo).Assembly.Location;
if (currentAssmeblyLocation.ToLower().Contains("_output")) return false;
var currentAssemblyLocation = typeof(RuntimeInfo).Assembly.Location;
if (currentAssemblyLocation.ToLower().Contains("_output")) return false;
}
catch
{
@ -139,6 +139,7 @@ namespace NzbDrone.Common.EnvironmentInfo
var lowerCurrentDir = Directory.GetCurrentDirectory().ToLower();
if (lowerCurrentDir.Contains("teamcity")) return false;
if (lowerCurrentDir.Contains("buildagent")) return false;
if (lowerCurrentDir.Contains("_output")) return false;
return true;

@ -6,8 +6,10 @@ namespace NzbDrone.Common.EnvironmentInfo
{
HashSet<string> Flags { get; }
Dictionary<string, string> Args { get; }
bool Help { get; }
bool InstallService { get; }
bool UninstallService { get; }
bool RegisterUrl { get; }
string PreservedArguments { get; }
}
@ -21,6 +23,7 @@ namespace NzbDrone.Common.EnvironmentInfo
public const string HELP = "?";
public const string TERMINATE = "terminateexisting";
public const string RESTART = "restart";
public const string REGISTER_URL = "registerurl";
public StartupContext(params string[] args)
{
@ -47,9 +50,10 @@ namespace NzbDrone.Common.EnvironmentInfo
public HashSet<string> Flags { get; private set; }
public Dictionary<string, string> Args { get; private set; }
public bool Help => Flags.Contains(HELP);
public bool InstallService => Flags.Contains(INSTALL_SERVICE);
public bool UninstallService => Flags.Contains(UNINSTALL_SERVICE);
public bool RegisterUrl => Flags.Contains(REGISTER_URL);
public string PreservedArguments
{
@ -71,4 +75,4 @@ namespace NzbDrone.Common.EnvironmentInfo
}
}
}
}
}

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace NzbDrone.Common.Extensions
{
public static class ExceptionExtensions
{
public static T WithData<T>(this T ex, string key, string value) where T : Exception
{
ex.AddData(key, value);
return ex;
}
public static T WithData<T>(this T ex, string key, int value) where T : Exception
{
ex.AddData(key, value.ToString());
return ex;
}
public static T WithData<T>(this T ex, string key, Http.HttpUri value) where T : Exception
{
ex.AddData(key, value.ToString());
return ex;
}
public static T WithData<T>(this T ex, Http.HttpResponse response, int maxSampleLength = 512) where T : Exception
{
if (response == null || response.Content == null) return ex;
var contentSample = response.Content.Substring(0, Math.Min(response.Content.Length, maxSampleLength));
if (response.Request != null)
{
ex.AddData("RequestUri", response.Request.Url.ToString());
if (response.Request.ContentSummary != null)
{
ex.AddData("RequestSummary", response.Request.ContentSummary);
}
}
ex.AddData("StatusCode", response.StatusCode.ToString());
if (response.Headers != null)
{
ex.AddData("ContentType", response.Headers.ContentType ?? string.Empty);
}
ex.AddData("ContentLength", response.Content.Length.ToString());
ex.AddData("ContentSample", contentSample);
return ex;
}
private static void AddData(this Exception ex, string key, string value)
{
if (value.IsNullOrWhiteSpace()) return;
ex.Data[key] = value;
}
}
}

@ -51,6 +51,34 @@ namespace NzbDrone.Common.Extensions
}
}
public static Dictionary<TKey, TItem> ToDictionaryIgnoreDuplicates<TItem, TKey>(this IEnumerable<TItem> src, Func<TItem, TKey> keySelector)
{
var result = new Dictionary<TKey, TItem>();
foreach (var item in src)
{
var key = keySelector(item);
if (!result.ContainsKey(key))
{
result[key] = item;
}
}
return result;
}
public static Dictionary<TKey, TValue> ToDictionaryIgnoreDuplicates<TItem, TKey, TValue>(this IEnumerable<TItem> src, Func<TItem, TKey> keySelector, Func<TItem, TValue> valueSelector)
{
var result = new Dictionary<TKey, TValue>();
foreach (var item in src)
{
var key = keySelector(item);
if (!result.ContainsKey(key))
{
result[key] = valueSelector(item);
}
}
return result;
}
public static void AddIfNotNull<TSource>(this List<TSource> source, TSource item)
{
if (item == null)

@ -25,6 +25,8 @@ namespace NzbDrone.Common.Extensions
private static readonly string UPDATE_CLIENT_FOLDER_NAME = "Radarr.Update" + Path.DirectorySeparatorChar;
private static readonly string UPDATE_LOG_FOLDER_NAME = "UpdateLogs" + Path.DirectorySeparatorChar;
private static readonly Regex PARENT_PATH_END_SLASH_REGEX = new Regex(@"(?<!:)\\$", RegexOptions.Compiled);
public static string CleanFilePath(this string path)
{
Ensure.That(path, () => path).IsNotNullOrWhiteSpace();
@ -60,7 +62,7 @@ namespace NzbDrone.Common.Extensions
{
if (!parentPath.IsParentPath(childPath))
{
throw new Exceptions.NotParentException("{0} is not a child of {1}", childPath, parentPath);
throw new NotParentException("{0} is not a child of {1}", childPath, parentPath);
}
return childPath.Substring(parentPath.Length).Trim(Path.DirectorySeparatorChar);
@ -68,24 +70,25 @@ namespace NzbDrone.Common.Extensions
public static string GetParentPath(this string childPath)
{
var parentPath = childPath.TrimEnd('\\', '/');
var index = parentPath.LastIndexOfAny(new[] { '\\', '/' });
var cleanPath = OsInfo.IsWindows
? PARENT_PATH_END_SLASH_REGEX.Replace(childPath, "")
: childPath.TrimEnd(Path.DirectorySeparatorChar);
if (index != -1)
if (cleanPath.IsNullOrWhiteSpace())
{
return parentPath.Substring(0, index);
return null;
}
return null;
return Directory.GetParent(cleanPath)?.FullName;
}
public static bool IsParentPath(this string parentPath, string childPath)
{
if (parentPath != "/")
if (parentPath != "/" && !parentPath.EndsWith(":\\"))
{
parentPath = parentPath.TrimEnd(Path.DirectorySeparatorChar);
}
if (childPath != "/")
if (childPath != "/" && !parentPath.EndsWith(":\\"))
{
childPath = childPath.TrimEnd(Path.DirectorySeparatorChar);
}
@ -192,6 +195,24 @@ namespace NzbDrone.Common.Extensions
return directories;
}
public static string GetAncestorPath(this string path, string ancestorName)
{
var parent = Path.GetDirectoryName(path);
while (parent != null)
{
var currentPath = parent;
parent = Path.GetDirectoryName(parent);
if (Path.GetFileName(currentPath) == ancestorName)
{
return currentPath;
}
}
return null;
}
public static string GetAppDataPath(this IAppFolderInfo appFolderInfo)
{
return appFolderInfo.AppDataFolder;

@ -0,0 +1,13 @@
using System;
using System.Text.RegularExpressions;
namespace NzbDrone.Common.Extensions
{
public static class RegexExtensions
{
public static int EndIndex(this Capture regexMatch)
{
return regexMatch.Index + regexMatch.Length;
}
}
}

@ -22,9 +22,14 @@ namespace NzbDrone.Common.Extensions
return "[NULL]";
}
public static string FirstCharToLower(this string input)
{
return input.First().ToString().ToLower() + input.Substring(1);
}
public static string FirstCharToUpper(this string input)
{
return input.First().ToString().ToUpper() + string.Join("", input.Skip(1));
return input.First().ToString().ToUpper() + input.Substring(1);
}
public static string Inject(this string format, params object[] formattingArgs)
@ -65,6 +70,7 @@ namespace NzbDrone.Common.Extensions
return text;
}
public static string Join(this IEnumerable<string> values, string separator)
{
return string.Join(separator, values);
@ -144,5 +150,10 @@ namespace NzbDrone.Common.Extensions
{
return CamelCaseRegex.Replace(input, match => " " + match.Value);
}
public static bool ContainsIgnoreCase(this IEnumerable<string> source, string value)
{
return source.Contains(value, StringComparer.InvariantCultureIgnoreCase);
}
}
}

@ -11,6 +11,11 @@ namespace NzbDrone.Common.Extensions
return false;
}
if (path.StartsWith(" ") || path.EndsWith(" "))
{
return false;
}
Uri uri;
if (!Uri.TryCreate(path, UriKind.Absolute, out uri))
{

@ -24,7 +24,13 @@ namespace NzbDrone.Common
}
}
}
return string.Format("{0:x8}", mCrc);
return $"{mCrc:x8}";
}
public static string AnonymousToken()
{
var seed = $"{Environment.ProcessorCount}_{Environment.OSVersion.Platform}_{Environment.MachineName}_{Environment.UserName}";
return HashUtil.CalculateCrc(seed);
}
}
}

@ -37,7 +37,7 @@ namespace NzbDrone.Common.Http
}
if (values.Length > 1)
{
throw new ApplicationException(string.Format("Expected {0} to occur only once.", key));
throw new ApplicationException($"Expected {key} to occur only once, but was {values.Join("|")}.");
}
return values[0];
@ -54,7 +54,7 @@ namespace NzbDrone.Common.Http
return converter(value);
}
protected void SetSingleValue(string key, string value)
{
{
if (value == null)
{
Remove(key);

@ -3,11 +3,12 @@ namespace NzbDrone.Common.Http
public enum HttpMethod
{
GET,
PUT,
POST,
HEAD,
PUT,
DELETE,
HEAD,
OPTIONS,
PATCH,
OPTIONS
MERGE
}
}

@ -58,6 +58,6 @@ namespace NzbDrone.Common.Http
}
}
}
}

@ -16,7 +16,8 @@ namespace NzbDrone.Common.Http
StoreRequestCookie = true;
IgnorePersistentCookies = false;
Cookies = new Dictionary<string, string>();
if (!RuntimeInfo.IsProduction)
{
AllowAutoRedirect = false;

@ -355,7 +355,7 @@ namespace NzbDrone.Common.Http
FormData.Add(new HttpFormData
{
Name = key,
ContentData = Encoding.UTF8.GetBytes(value.ToString())
ContentData = Encoding.UTF8.GetBytes(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture))
});
return this;

@ -168,7 +168,7 @@ namespace NzbDrone.Common.Http
{
return basePath.Substring(0, baseSlashIndex) + "/" + relativePath;
}
return relativePath;
}
@ -263,7 +263,7 @@ namespace NzbDrone.Common.Http
{
return new HttpUri(baseUrl.Scheme, baseUrl.Host, baseUrl.Port, CombineRelativePath(baseUrl.Path, relativeUrl.Path), relativeUrl.Query, relativeUrl.Fragment);
}
return new HttpUri(baseUrl.Scheme, baseUrl.Host, baseUrl.Port, baseUrl.Path, relativeUrl.Query, relativeUrl.Fragment);
}
}

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Newtonsoft.Json.Linq;
using NzbDrone.Common.Serializer;
namespace NzbDrone.Common.Instrumentation
{
public class CleansingJsonVisitor : JsonVisitor
{
public override void Visit(JArray json)
{
for (var i = 0; i < json.Count; i++)
{
if (json[i].Type == JTokenType.String)
{
var text = json[i].Value<string>();
json[i] = new JValue(CleanseLogMessage.Cleanse(text));
}
}
foreach (JToken token in json)
{
Visit(token);
}
}
public override void Visit(JProperty property)
{
if (property.Value.Type == JTokenType.String)
{
property.Value = CleanseValue(property.Value as JValue);
}
else
{
base.Visit(property);
}
}
private JValue CleanseValue(JValue value)
{
var text = value.Value<string>();
var cleansed = CleanseLogMessage.Cleanse(text);
return new JValue(cleansed);
}
}
}

@ -19,7 +19,7 @@ namespace NzbDrone.Common.Instrumentation
var exception = e.Exception;
Console.WriteLine("Task Error: {0}", exception);
Logger.Error(exception, "Task Error: " + exception.Message);
Logger.Error(exception, "Task Error");
}
private static void HandleAppDomainException(object sender, UnhandledExceptionEventArgs e)
@ -31,7 +31,7 @@ namespace NzbDrone.Common.Instrumentation
if (exception is NullReferenceException &&
exception.ToString().Contains("Microsoft.AspNet.SignalR.Transports.TransportHeartbeat.ProcessServerCommand"))
{
Logger.Warn("SignalR Heartbeat interupted");
Logger.Warn("SignalR Heartbeat interrupted");
return;
}
@ -44,11 +44,9 @@ namespace NzbDrone.Common.Instrumentation
return;
}
}
Console.WriteLine(exception.StackTrace);
Console.WriteLine("EPIC FAIL: {0}", exception);
Logger.Fatal(exception, "EPIC FAIL: " + exception.Message);
Logger.Fatal(exception, "EPIC FAIL.");
}
}
}

@ -88,10 +88,13 @@
<Compile Include="ConsoleService.cs" />
<Compile Include="ConvertBase32.cs" />
<Compile Include="Crypto\HashProvider.cs" />
<Compile Include="Disk\DestinationAlreadyExistsException.cs" />
<Compile Include="Disk\FileSystemLookupService.cs" />
<Compile Include="Disk\DriveInfoMount.cs" />
<Compile Include="Disk\IMount.cs" />
<Compile Include="Disk\LongPathSupport.cs" />
<Compile Include="Disk\MountOptions.cs" />
<Compile Include="Disk\NotParentException.cs" />
<Compile Include="Disk\RelativeFileSystemModel.cs" />
<Compile Include="Disk\FileSystemModel.cs" />
<Compile Include="Disk\FileSystemResult.cs" />
@ -161,9 +164,11 @@
<Compile Include="Extensions\Base64Extensions.cs" />
<Compile Include="Extensions\DateTimeExtensions.cs" />
<Compile Include="Crypto\HashConverter.cs" />
<Compile Include="Extensions\ExceptionExtensions.cs" />
<Compile Include="Extensions\Int64Extensions.cs" />
<Compile Include="Extensions\IpAddressExtensions.cs" />
<Compile Include="Extensions\ObjectExtensions.cs" />
<Compile Include="Extensions\RegexExtensions.cs" />
<Compile Include="Extensions\StreamExtensions.cs" />
<Compile Include="Extensions\UrlExtensions.cs" />
<Compile Include="Extensions\XmlExtensions.cs" />
@ -203,6 +208,7 @@
<Compile Include="Http\UnexpectedHtmlContentException.cs" />
<Compile Include="Http\UserAgentBuilder.cs" />
<Compile Include="Instrumentation\CleanseLogMessage.cs" />
<Compile Include="Instrumentation\CleansingJsonVisitor.cs" />
<Compile Include="Instrumentation\Extensions\LoggerProgressExtensions.cs" />
<Compile Include="Instrumentation\GlobalExceptionHandlers.cs" />
<Compile Include="Instrumentation\LogEventExtensions.cs" />
@ -226,6 +232,7 @@
<Compile Include="Serializer\HttpUriConverter.cs" />
<Compile Include="Serializer\IntConverter.cs" />
<Compile Include="Serializer\Json.cs" />
<Compile Include="Serializer\JsonVisitor.cs" />
<Compile Include="Serializer\UnderscoreStringEnumConverter.cs" />
<Compile Include="ServiceFactory.cs" />
<Compile Include="ServiceProvider.cs" />

@ -60,6 +60,11 @@ namespace NzbDrone.Common.Reflection
return (T)attribute;
}
public static T[] GetAttributes<T>(this MemberInfo member) where T : Attribute
{
return member.GetCustomAttributes(typeof(T), false).OfType<T>().ToArray();
}
public static Type FindTypeByName(this Assembly assembly, string name)
{
return assembly.GetTypes().SingleOrDefault(c => c.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase));

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

Loading…
Cancel
Save