mirror of
https://github.com/Radarr/Radarr.git
synced 2026-04-16 21:15:33 -04:00
Compare commits
106 Commits
v5.5.0.873
...
v5.8.0.889
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f1cf21c39 | ||
|
|
92a19a1a81 | ||
|
|
54965cfa6f | ||
|
|
14f27cf2b6 | ||
|
|
a607f167f4 | ||
|
|
29449e83f9 | ||
|
|
bb4e185644 | ||
|
|
085b1db77f | ||
|
|
7bdb3e437d | ||
|
|
fcb0d8a930 | ||
|
|
7dc64c595c | ||
|
|
9a2b4bc81d | ||
|
|
f228841dc7 | ||
|
|
02be9cf825 | ||
|
|
8809c207bb | ||
|
|
1be2cded74 | ||
|
|
0a189d00ef | ||
|
|
5fc63ecb3f | ||
|
|
3a74393d05 | ||
|
|
4cbf5cfc57 | ||
|
|
797142d6f3 | ||
|
|
2a472c50c1 | ||
|
|
a12ff68fbd | ||
|
|
194926c7dd | ||
|
|
7dee5bb689 | ||
|
|
9b24dab71b | ||
|
|
62e1c02fe2 | ||
|
|
99b3d61862 | ||
|
|
bd905567de | ||
|
|
a8eea20d69 | ||
|
|
69ad0caf40 | ||
|
|
8a5c0ffd18 | ||
|
|
c8b409ed0b | ||
|
|
c5bcb13f63 | ||
|
|
80de711654 | ||
|
|
3fb558411e | ||
|
|
98384ab390 | ||
|
|
0c654377f4 | ||
|
|
e8c925274a | ||
|
|
320bfeec16 | ||
|
|
638f92495c | ||
|
|
077b041d3f | ||
|
|
ff3dd3ae42 | ||
|
|
2e3beddcbc | ||
|
|
dc068bbf3d | ||
|
|
7a303c1ebf | ||
|
|
152f50a1ef | ||
|
|
9798202589 | ||
|
|
7969776339 | ||
|
|
288982d7bd | ||
|
|
d39a3ade5b | ||
|
|
1fc6e88bc4 | ||
|
|
e8e1841e6c | ||
|
|
d17eb4f33f | ||
|
|
685f462959 | ||
|
|
7be8a34130 | ||
|
|
886711b496 | ||
|
|
5185e037da | ||
|
|
38e7e37d57 | ||
|
|
190c4c5893 | ||
|
|
0ec18ce4b3 | ||
|
|
a08575b7bc | ||
|
|
556cc885ec | ||
|
|
586c0c6e13 | ||
|
|
cec569461d | ||
|
|
8b79b5afbf | ||
|
|
cd4552ce6f | ||
|
|
256439304b | ||
|
|
bb44fbc362 | ||
|
|
cd401f72f5 | ||
|
|
c9624e7550 | ||
|
|
649702eaca | ||
|
|
1c52f0f5bd | ||
|
|
dff85dc1f3 | ||
|
|
1090aeff75 | ||
|
|
086a0addba | ||
|
|
8b6cf34ce4 | ||
|
|
7f03a916f1 | ||
|
|
3a6d603a9e | ||
|
|
cd2c7dc7fb | ||
|
|
f1d76c3483 | ||
|
|
39eac4b5ad | ||
|
|
71e1003358 | ||
|
|
89b6a5d51f | ||
|
|
711637c448 | ||
|
|
2677d25980 | ||
|
|
56639bcd42 | ||
|
|
1ed62b9ced | ||
|
|
a596dda253 | ||
|
|
c0b354039d | ||
|
|
3b5078d117 | ||
|
|
db1fee8d8a | ||
|
|
0d0575f3a9 | ||
|
|
2d82347a66 | ||
|
|
25838df550 | ||
|
|
b3a8b99f9a | ||
|
|
93a852841f | ||
|
|
ead1ec43be | ||
|
|
04b6dd44cb | ||
|
|
3db78079f3 | ||
|
|
c8a6b9f565 | ||
|
|
811cafd9ae | ||
|
|
ac7039d651 | ||
|
|
a2d11cf684 | ||
|
|
cc32635f6f | ||
|
|
10f9cb64ac |
@@ -9,18 +9,18 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '5.5.0'
|
||||
majorVersion: '5.8.0'
|
||||
minorVersion: $[counter('minorVersion', 2000)]
|
||||
radarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
|
||||
sentryOrg: 'servarr'
|
||||
sentryUrl: 'https://sentry.servarr.com'
|
||||
dotnetVersion: '6.0.417'
|
||||
dotnetVersion: '6.0.421'
|
||||
nodeVersion: '20.X'
|
||||
innoVersion: '6.2.2'
|
||||
windowsImage: 'windows-2022'
|
||||
linuxImage: 'ubuntu-20.04'
|
||||
macImage: 'macOS-11'
|
||||
macImage: 'macOS-12'
|
||||
|
||||
trigger:
|
||||
branches:
|
||||
@@ -166,10 +166,10 @@ stages:
|
||||
pool:
|
||||
vmImage: $(imageName)
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
- task: UseNode@1
|
||||
displayName: Set Node.js version
|
||||
inputs:
|
||||
versionSpec: $(nodeVersion)
|
||||
version: $(nodeVersion)
|
||||
- checkout: self
|
||||
submodules: true
|
||||
fetchDepth: 1
|
||||
@@ -1089,10 +1089,10 @@ stages:
|
||||
pool:
|
||||
vmImage: $(imageName)
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
- task: UseNode@1
|
||||
displayName: Set Node.js version
|
||||
inputs:
|
||||
versionSpec: $(nodeVersion)
|
||||
version: $(nodeVersion)
|
||||
- checkout: self
|
||||
submodules: true
|
||||
fetchDepth: 1
|
||||
|
||||
10
docs.sh
10
docs.sh
@@ -21,15 +21,21 @@ slnFile=src/Radarr.sln
|
||||
|
||||
platform=Posix
|
||||
|
||||
if [ "$PLATFORM" = "Windows" ]; then
|
||||
application=Radarr.Console.dll
|
||||
else
|
||||
application=Radarr.dll
|
||||
fi
|
||||
|
||||
dotnet clean $slnFile -c Debug
|
||||
dotnet clean $slnFile -c Release
|
||||
|
||||
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
|
||||
|
||||
dotnet new tool-manifest
|
||||
dotnet tool install --version 6.5.0 Swashbuckle.AspNetCore.Cli
|
||||
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
|
||||
|
||||
dotnet tool run swagger tofile --output ./src/Radarr.Api.V3/openapi.json "$outputFolder/net6.0/$RUNTIME/radarr.console.dll" v3 &
|
||||
dotnet tool run swagger tofile --output ./src/Radarr.Api.V3/openapi.json "$outputFolder/net6.0/$RUNTIME/$application" v3 &
|
||||
|
||||
sleep 45
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
@@ -20,6 +21,7 @@ import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import BlocklistFilterModal from './BlocklistFilterModal';
|
||||
import BlocklistRowConnector from './BlocklistRowConnector';
|
||||
|
||||
class Blocklist extends Component {
|
||||
@@ -114,9 +116,13 @@ class Blocklist extends Component {
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
customFilters,
|
||||
totalRecords,
|
||||
isRemoving,
|
||||
isClearingBlocklistExecuting,
|
||||
onFilterSelect,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
@@ -161,6 +167,15 @@ class Blocklist extends Component {
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={BlocklistFilterModal}
|
||||
onFilterSelect={onFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
@@ -180,7 +195,11 @@ class Blocklist extends Component {
|
||||
{
|
||||
isPopulated && !error && !items.length &&
|
||||
<Alert kind={kinds.INFO}>
|
||||
{translate('NoHistoryBlocklist')}
|
||||
{
|
||||
selectedFilterKey === 'all' ?
|
||||
translate('NoHistoryBlocklist') :
|
||||
translate('BlocklistFilterHasNoItems')
|
||||
}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
@@ -251,11 +270,15 @@ Blocklist.propTypes = {
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
totalRecords: PropTypes.number,
|
||||
isRemoving: PropTypes.bool.isRequired,
|
||||
isClearingBlocklistExecuting: PropTypes.bool.isRequired,
|
||||
onRemoveSelected: PropTypes.func.isRequired,
|
||||
onClearBlocklistPress: PropTypes.func.isRequired
|
||||
onClearBlocklistPress: PropTypes.func.isRequired,
|
||||
onFilterSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Blocklist;
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as commandNames from 'Commands/commandNames';
|
||||
import withCurrentPage from 'Components/withCurrentPage';
|
||||
import * as blocklistActions from 'Store/Actions/blocklistActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||
import Blocklist from './Blocklist';
|
||||
@@ -13,10 +14,12 @@ import Blocklist from './Blocklist';
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.blocklist,
|
||||
createCustomFiltersSelector('blocklist'),
|
||||
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST),
|
||||
(blocklist, isClearingBlocklistExecuting) => {
|
||||
(blocklist, customFilters, isClearingBlocklistExecuting) => {
|
||||
return {
|
||||
isClearingBlocklistExecuting,
|
||||
customFilters,
|
||||
...blocklist
|
||||
};
|
||||
}
|
||||
@@ -97,6 +100,14 @@ class BlocklistConnector extends Component {
|
||||
this.props.setBlocklistSort({ sortKey });
|
||||
};
|
||||
|
||||
onFilterSelect = (selectedFilterKey) => {
|
||||
this.props.setBlocklistFilter({ selectedFilterKey });
|
||||
};
|
||||
|
||||
onClearBlocklistPress = () => {
|
||||
this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST });
|
||||
};
|
||||
|
||||
onTableOptionChange = (payload) => {
|
||||
this.props.setBlocklistTableOption(payload);
|
||||
|
||||
@@ -105,10 +116,6 @@ class BlocklistConnector extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
onClearBlocklistPress = () => {
|
||||
this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
@@ -122,6 +129,7 @@ class BlocklistConnector extends Component {
|
||||
onPageSelect={this.onPageSelect}
|
||||
onRemoveSelected={this.onRemoveSelected}
|
||||
onSortPress={this.onSortPress}
|
||||
onFilterSelect={this.onFilterSelect}
|
||||
onTableOptionChange={this.onTableOptionChange}
|
||||
onClearBlocklistPress={this.onClearBlocklistPress}
|
||||
{...this.props}
|
||||
@@ -142,6 +150,7 @@ BlocklistConnector.propTypes = {
|
||||
gotoBlocklistPage: PropTypes.func.isRequired,
|
||||
removeBlocklistItems: PropTypes.func.isRequired,
|
||||
setBlocklistSort: PropTypes.func.isRequired,
|
||||
setBlocklistFilter: PropTypes.func.isRequired,
|
||||
setBlocklistTableOption: PropTypes.func.isRequired,
|
||||
clearBlocklist: PropTypes.func.isRequired,
|
||||
executeCommand: PropTypes.func.isRequired
|
||||
|
||||
54
frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx
Normal file
54
frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal from 'Components/Filter/FilterModal';
|
||||
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
|
||||
|
||||
function createBlocklistSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.blocklist.items,
|
||||
(blocklistItems) => {
|
||||
return blocklistItems;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.blocklist.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface BlocklistFilterModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
||||
const sectionItems = useSelector(createBlocklistSelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'blocklist';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: unknown) => {
|
||||
dispatch(setBlocklistFilter(payload));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
// TODO: Don't spread all the props
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -219,6 +219,7 @@ class Queue extends Component {
|
||||
>
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
maxPageSize={200}
|
||||
{...otherProps}
|
||||
optionsComponent={QueueOptionsConnector}
|
||||
>
|
||||
|
||||
@@ -70,6 +70,11 @@ function QueueStatus(props) {
|
||||
iconName = icons.DOWNLOADED;
|
||||
title = translate('Downloaded');
|
||||
|
||||
if (trackedDownloadState === 'importBlocked') {
|
||||
title += ` - ${translate('UnableToImportAutomatically')}`;
|
||||
iconKind = kinds.WARNING;
|
||||
}
|
||||
|
||||
if (trackedDownloadState === 'importPending') {
|
||||
title += ` - ${translate('WaitingToImport')}`;
|
||||
iconKind = kinds.PURPLE;
|
||||
|
||||
@@ -12,11 +12,10 @@ function App({ store, history }) {
|
||||
<DocumentTitle title={window.Radarr.instanceName}>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme>
|
||||
<PageConnector>
|
||||
<AppRoutes app={App} />
|
||||
</PageConnector>
|
||||
</ApplyTheme>
|
||||
<ApplyTheme />
|
||||
<PageConnector>
|
||||
<AppRoutes app={App} />
|
||||
</PageConnector>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
</DocumentTitle>
|
||||
|
||||
@@ -33,6 +33,8 @@ import Status from 'System/Status/Status';
|
||||
import Tasks from 'System/Tasks/Tasks';
|
||||
import UpdatesConnector from 'System/Updates/UpdatesConnector';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
|
||||
import MissingConnector from 'Wanted/Missing/MissingConnector';
|
||||
|
||||
function AppRoutes(props) {
|
||||
const {
|
||||
@@ -121,6 +123,20 @@ function AppRoutes(props) {
|
||||
component={BlocklistConnector}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Wanted
|
||||
*/}
|
||||
|
||||
<Route
|
||||
path="/wanted/missing"
|
||||
component={MissingConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/wanted/cutoffunmet"
|
||||
component={CutoffUnmetConnector}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Settings
|
||||
*/}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Fragment, useCallback, useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import themes from 'Styles/Themes';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.ui.item.theme || window.Radarr.theme,
|
||||
(
|
||||
theme
|
||||
) => {
|
||||
return {
|
||||
theme
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function ApplyTheme({ theme, children }) {
|
||||
// Update the CSS Variables
|
||||
const updateCSSVariables = useCallback(() => {
|
||||
const arrayOfVariableKeys = Object.keys(themes[theme]);
|
||||
const arrayOfVariableValues = Object.values(themes[theme]);
|
||||
|
||||
// Loop through each array key and set the CSS Variables
|
||||
arrayOfVariableKeys.forEach((cssVariableKey, index) => {
|
||||
// Based on our snippet from MDN
|
||||
document.documentElement.style.setProperty(
|
||||
`--${cssVariableKey}`,
|
||||
arrayOfVariableValues[index]
|
||||
);
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
// On Component Mount and Component Update
|
||||
useEffect(() => {
|
||||
updateCSSVariables(theme);
|
||||
}, [updateCSSVariables, theme]);
|
||||
|
||||
return <Fragment>{children}</Fragment>;
|
||||
}
|
||||
|
||||
ApplyTheme.propTypes = {
|
||||
theme: PropTypes.string.isRequired,
|
||||
children: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps)(ApplyTheme);
|
||||
37
frontend/src/App/ApplyTheme.tsx
Normal file
37
frontend/src/App/ApplyTheme.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { Fragment, ReactNode, useCallback, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import themes from 'Styles/Themes';
|
||||
import AppState from './State/AppState';
|
||||
|
||||
interface ApplyThemeProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function createThemeSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.ui.item.theme || window.Radarr.theme,
|
||||
(theme) => {
|
||||
return theme;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function ApplyTheme({ children }: ApplyThemeProps) {
|
||||
const theme = useSelector(createThemeSelector());
|
||||
|
||||
const updateCSSVariables = useCallback(() => {
|
||||
Object.entries(themes[theme]).forEach(([key, value]) => {
|
||||
document.documentElement.style.setProperty(`--${key}`, value);
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
// On Component Mount and Component Update
|
||||
useEffect(() => {
|
||||
updateCSSVariables();
|
||||
}, [updateCSSVariables, theme]);
|
||||
|
||||
return <Fragment>{children}</Fragment>;
|
||||
}
|
||||
|
||||
export default ApplyTheme;
|
||||
@@ -1,4 +1,5 @@
|
||||
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
|
||||
import BlocklistAppState from './BlocklistAppState';
|
||||
import CalendarAppState from './CalendarAppState';
|
||||
import CommandAppState from './CommandAppState';
|
||||
import HistoryAppState from './HistoryAppState';
|
||||
@@ -54,6 +55,7 @@ export interface AppSectionState {
|
||||
|
||||
interface AppState {
|
||||
app: AppSectionState;
|
||||
blocklist: BlocklistAppState;
|
||||
calendar: CalendarAppState;
|
||||
commands: CommandAppState;
|
||||
history: HistoryAppState;
|
||||
|
||||
8
frontend/src/App/State/BlocklistAppState.ts
Normal file
8
frontend/src/App/State/BlocklistAppState.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import Blocklist from 'typings/Blocklist';
|
||||
import AppSectionState, { AppSectionFilterState } from './AppSectionState';
|
||||
|
||||
interface BlocklistAppState
|
||||
extends AppSectionState<Blocklist>,
|
||||
AppSectionFilterState<Blocklist> {}
|
||||
|
||||
export default BlocklistAppState;
|
||||
@@ -115,3 +115,16 @@ $hoverScale: 1.05;
|
||||
color: var(--iconButtonHoverLightColor);
|
||||
}
|
||||
}
|
||||
|
||||
.excluded {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-width: 0 25px 25px 0;
|
||||
border-style: solid;
|
||||
border-color: transparent var(--dangerColor) transparent transparent;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ interface CssExports {
|
||||
'content': string;
|
||||
'controls': string;
|
||||
'editorSelect': string;
|
||||
'excluded': string;
|
||||
'externalLinks': string;
|
||||
'link': string;
|
||||
'monitorToggleButton': string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
|
||||
import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
|
||||
import MoviePoster from 'Movie/MoviePoster';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AddNewCollectionMovieModal from './../AddNewCollectionMovieModal';
|
||||
import styles from './CollectionMovie.css';
|
||||
|
||||
@@ -72,6 +73,7 @@ class CollectionMovie extends Component {
|
||||
isAvailable,
|
||||
movieFile,
|
||||
isExistingMovie,
|
||||
isExcluded,
|
||||
posterWidth,
|
||||
posterHeight,
|
||||
detailedProgressBar,
|
||||
@@ -107,6 +109,15 @@ class CollectionMovie extends Component {
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
isExcluded ?
|
||||
<div
|
||||
className={styles.excluded}
|
||||
title={translate('Excluded')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
<Link
|
||||
className={styles.link}
|
||||
style={elementStyle}
|
||||
@@ -189,6 +200,7 @@ CollectionMovie.propTypes = {
|
||||
posterHeight: PropTypes.number.isRequired,
|
||||
detailedProgressBar: PropTypes.bool.isRequired,
|
||||
isExistingMovie: PropTypes.bool,
|
||||
isExcluded: PropTypes.bool,
|
||||
tmdbId: PropTypes.number.isRequired,
|
||||
imdbId: PropTypes.string,
|
||||
youTubeTrailerId: PropTypes.string,
|
||||
|
||||
@@ -271,26 +271,32 @@ class EnhancedSelectInput extends Component {
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
};
|
||||
|
||||
onSelect = (value) => {
|
||||
if (Array.isArray(this.props.value)) {
|
||||
let newValue = null;
|
||||
const index = this.props.value.indexOf(value);
|
||||
onSelect = (newValue) => {
|
||||
const { name, value, values, onChange } = this.props;
|
||||
const additionalProperties = values.find((v) => v.key === newValue)?.additionalProperties;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let arrayValue = null;
|
||||
const index = value.indexOf(newValue);
|
||||
|
||||
if (index === -1) {
|
||||
newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v));
|
||||
arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v));
|
||||
} else {
|
||||
newValue = [...this.props.value];
|
||||
newValue.splice(index, 1);
|
||||
arrayValue = [...value];
|
||||
arrayValue.splice(index, 1);
|
||||
}
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: newValue
|
||||
onChange({
|
||||
name,
|
||||
value: arrayValue,
|
||||
additionalProperties
|
||||
});
|
||||
} else {
|
||||
this.setState({ isOpen: false });
|
||||
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value
|
||||
onChange({
|
||||
name,
|
||||
value: newValue,
|
||||
additionalProperties
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -485,7 +491,7 @@ class EnhancedSelectInput extends Component {
|
||||
values.map((v, index) => {
|
||||
const hasParent = v.parentKey !== undefined;
|
||||
const depth = hasParent ? 1 : 0;
|
||||
const parentSelected = hasParent && value.includes(v.parentKey);
|
||||
const parentSelected = hasParent && Array.isArray(value) && value.includes(v.parentKey);
|
||||
return (
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
|
||||
@@ -9,7 +9,8 @@ import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
const importantFieldNames = [
|
||||
'baseUrl',
|
||||
'apiPath',
|
||||
'apiKey'
|
||||
'apiKey',
|
||||
'authToken'
|
||||
];
|
||||
|
||||
function getProviderDataKey(providerData) {
|
||||
@@ -34,7 +35,9 @@ function getSelectOptions(items) {
|
||||
key: option.value,
|
||||
value: option.name,
|
||||
hint: option.hint,
|
||||
parentKey: option.parentValue
|
||||
parentKey: option.parentValue,
|
||||
isDisabled: option.isDisabled,
|
||||
additionalProperties: option.additionalProperties
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -147,7 +150,7 @@ EnhancedSelectInputConnector.propTypes = {
|
||||
provider: PropTypes.string.isRequired,
|
||||
providerData: PropTypes.object.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
selectOptionsProviderAction: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.input {
|
||||
composes: input from '~Components/Form/TextInput.css';
|
||||
|
||||
font-family: $passwordFamily;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import TextInput from './TextInput';
|
||||
import styles from './PasswordInput.css';
|
||||
|
||||
// Prevent a user from copying (or cutting) the password from the input
|
||||
function onCopy(e) {
|
||||
@@ -13,17 +11,14 @@ function PasswordInput(props) {
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
type="password"
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
PasswordInput.propTypes = {
|
||||
className: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
PasswordInput.defaultProps = {
|
||||
className: styles.input
|
||||
...TextInput.props
|
||||
};
|
||||
|
||||
export default PasswordInput;
|
||||
|
||||
@@ -71,6 +71,22 @@ const links = [
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
iconName: icons.WARNING,
|
||||
title: () => translate('Wanted'),
|
||||
to: '/wanted/missing',
|
||||
children: [
|
||||
{
|
||||
title: () => translate('Missing'),
|
||||
to: '/wanted/missing'
|
||||
},
|
||||
{
|
||||
title: () => translate('CutoffUnmet'),
|
||||
to: '/wanted/cutoffunmet'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
iconName: icons.SETTINGS,
|
||||
title: () => translate('Settings'),
|
||||
|
||||
@@ -244,6 +244,26 @@ class SignalRConnector extends Component {
|
||||
this.props.dispatchSetVersion({ version });
|
||||
};
|
||||
|
||||
handleWantedCutoff = (body) => {
|
||||
if (body.action === 'updated') {
|
||||
this.props.dispatchUpdateItem({
|
||||
section: 'wanted.cutoffUnmet',
|
||||
updateOnly: true,
|
||||
...body.resource
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleWantedMissing = (body) => {
|
||||
if (body.action === 'updated') {
|
||||
this.props.dispatchUpdateItem({
|
||||
section: 'wanted.missing',
|
||||
updateOnly: true,
|
||||
...body.resource
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleSystemTask = () => {
|
||||
this.props.dispatchFetchCommands();
|
||||
};
|
||||
|
||||
@@ -49,11 +49,12 @@ class TableOptionsModal extends Component {
|
||||
|
||||
onPageSizeChange = ({ value }) => {
|
||||
let pageSizeError = null;
|
||||
const maxPageSize = this.props.maxPageSize ?? 250;
|
||||
|
||||
if (value < 5) {
|
||||
pageSizeError = translate('TablePageSizeMinimum', { minimumValue: '5' });
|
||||
} else if (value > 250) {
|
||||
pageSizeError = translate('TablePageSizeMaximum', { maximumValue: '250' });
|
||||
} else if (value > maxPageSize) {
|
||||
pageSizeError = translate('TablePageSizeMaximum', { maximumValue: `${maxPageSize}` });
|
||||
} else {
|
||||
this.props.onTableOptionChange({ pageSize: value });
|
||||
}
|
||||
@@ -248,6 +249,7 @@ TableOptionsModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
pageSize: PropTypes.number,
|
||||
maxPageSize: PropTypes.number,
|
||||
canModifyColumns: PropTypes.bool.isRequired,
|
||||
optionsComponent: PropTypes.elementType,
|
||||
onTableOptionChange: PropTypes.func.isRequired,
|
||||
|
||||
@@ -25,14 +25,3 @@
|
||||
font-family: 'Ubuntu Mono';
|
||||
src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype');
|
||||
}
|
||||
|
||||
/*
|
||||
* text-security-disc
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-family: 'text-security-disc';
|
||||
src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') format('truetype');
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -75,9 +75,19 @@ class DiscoverMovie extends Component {
|
||||
const {
|
||||
items,
|
||||
sortKey,
|
||||
sortDirection
|
||||
sortDirection,
|
||||
includeRecommendations,
|
||||
includeTrending,
|
||||
includePopular
|
||||
} = this.props;
|
||||
|
||||
if (includeRecommendations !== prevProps.includeRecommendations ||
|
||||
includeTrending !== prevProps.includeTrending ||
|
||||
includePopular !== prevProps.includePopular
|
||||
) {
|
||||
this.props.dispatchFetchListMovies();
|
||||
}
|
||||
|
||||
if (sortKey !== prevProps.sortKey ||
|
||||
sortDirection !== prevProps.sortDirection ||
|
||||
hasDifferentItemsOrOrder(prevProps.items, items)
|
||||
@@ -443,6 +453,9 @@ DiscoverMovie.propTypes = {
|
||||
sortKey: PropTypes.string,
|
||||
sortDirection: PropTypes.oneOf(sortDirections.all),
|
||||
view: PropTypes.string.isRequired,
|
||||
includeRecommendations: PropTypes.bool.isRequired,
|
||||
includeTrending: PropTypes.bool.isRequired,
|
||||
includePopular: PropTypes.bool.isRequired,
|
||||
isSyncingLists: PropTypes.bool.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
onSortSelect: PropTypes.func.isRequired,
|
||||
@@ -451,7 +464,8 @@ DiscoverMovie.propTypes = {
|
||||
onScroll: PropTypes.func.isRequired,
|
||||
onAddMoviesPress: PropTypes.func.isRequired,
|
||||
onExcludeMoviesPress: PropTypes.func.isRequired,
|
||||
onImportListSyncPress: PropTypes.func.isRequired
|
||||
onImportListSyncPress: PropTypes.func.isRequired,
|
||||
dispatchFetchListMovies: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DiscoverMovie;
|
||||
|
||||
@@ -17,15 +17,18 @@ import DiscoverMovie from './DiscoverMovie';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.discoverMovie,
|
||||
createDiscoverMovieClientSideCollectionItemsSelector('discoverMovie'),
|
||||
createCommandExecutingSelector(commandNames.IMPORT_LIST_SYNC),
|
||||
createDimensionsSelector(),
|
||||
(
|
||||
discoverMovie,
|
||||
movies,
|
||||
isSyncingLists,
|
||||
dimensionsState
|
||||
) => {
|
||||
return {
|
||||
...discoverMovie.options,
|
||||
...movies,
|
||||
isSyncingLists,
|
||||
isSmallScreen: dimensionsState.isSmallScreen
|
||||
|
||||
@@ -49,7 +49,9 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
|
||||
showRatings: props.showRatings,
|
||||
showYear: props.showYear,
|
||||
showGenres: props.showGenres,
|
||||
includeRecommendations: props.includeRecommendations
|
||||
includeRecommendations: props.includeRecommendations,
|
||||
includeTrending: props.includeTrending,
|
||||
includePopular: props.includePopular
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,7 +63,9 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
|
||||
showRatings,
|
||||
showCertification,
|
||||
showGenres,
|
||||
includeRecommendations
|
||||
includeRecommendations,
|
||||
includeTrending,
|
||||
includePopular
|
||||
} = this.props;
|
||||
|
||||
const state = {};
|
||||
@@ -94,6 +98,14 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
|
||||
state.includeRecommendations = includeRecommendations;
|
||||
}
|
||||
|
||||
if (includeTrending !== prevProps.includeTrending) {
|
||||
state.includeTrending = includeTrending;
|
||||
}
|
||||
|
||||
if (includePopular !== prevProps.includePopular) {
|
||||
state.includePopular = includePopular;
|
||||
}
|
||||
|
||||
if (!_.isEmpty(state)) {
|
||||
this.setState(state);
|
||||
}
|
||||
@@ -135,19 +147,22 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
|
||||
showRatings,
|
||||
showYear,
|
||||
showGenres,
|
||||
includeRecommendations
|
||||
includeRecommendations,
|
||||
includeTrending,
|
||||
includePopular
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Overview Options
|
||||
{translate('OverviewOptions')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeRadarrRecommendations')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includeRecommendations"
|
||||
@@ -157,6 +172,30 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeTrending')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includeTrending"
|
||||
value={includeTrending}
|
||||
helpText={translate('IncludeTrendingMoviesHelpText')}
|
||||
onChange={this.onChangeOption}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludePopular')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includePopular"
|
||||
value={includePopular}
|
||||
helpText={translate('IncludePopularMoviesHelpText')}
|
||||
onChange={this.onChangeOption}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('PosterSize')}</FormLabel>
|
||||
|
||||
@@ -246,6 +285,8 @@ DiscoverMovieOverviewOptionsModalContent.propTypes = {
|
||||
showCertification: PropTypes.bool.isRequired,
|
||||
showGenres: PropTypes.bool.isRequired,
|
||||
includeRecommendations: PropTypes.bool.isRequired,
|
||||
includeTrending: PropTypes.bool.isRequired,
|
||||
includePopular: PropTypes.bool.isRequired,
|
||||
onChangeOverviewOption: PropTypes.func.isRequired,
|
||||
onChangeOption: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import TmdbRating from 'Components/TmdbRating';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { getMovieStatusDetails } from 'Movie/MovieStatus';
|
||||
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
|
||||
import formatRuntime from 'Utilities/Date/formatRuntime';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
@@ -45,7 +45,9 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
|
||||
this.state = {
|
||||
size: props.size,
|
||||
showTitle: props.showTitle,
|
||||
includeRecommendations: props.includeRecommendations
|
||||
includeRecommendations: props.includeRecommendations,
|
||||
includeTrending: props.includeTrending,
|
||||
includePopular: props.includePopular
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,7 +55,9 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
|
||||
const {
|
||||
size,
|
||||
showTitle,
|
||||
includeRecommendations
|
||||
includeRecommendations,
|
||||
includeTrending,
|
||||
includePopular
|
||||
} = this.props;
|
||||
|
||||
const state = {};
|
||||
@@ -70,6 +74,14 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
|
||||
state.includeRecommendations = includeRecommendations;
|
||||
}
|
||||
|
||||
if (includeTrending !== prevProps.includeTrending) {
|
||||
state.includeTrending = includeTrending;
|
||||
}
|
||||
|
||||
if (includePopular !== prevProps.includePopular) {
|
||||
state.includePopular = includePopular;
|
||||
}
|
||||
|
||||
if (!_.isEmpty(state)) {
|
||||
this.setState(state);
|
||||
}
|
||||
@@ -107,13 +119,15 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
|
||||
const {
|
||||
size,
|
||||
showTitle,
|
||||
includeRecommendations
|
||||
includeRecommendations,
|
||||
includeTrending,
|
||||
includePopular
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Poster Options
|
||||
{translate('PosterOptions')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
@@ -130,6 +144,30 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeTrending')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includeTrending"
|
||||
value={includeTrending}
|
||||
helpText={translate('IncludeTrendingMoviesHelpText')}
|
||||
onChange={this.onChangeOption}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludePopular')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includePopular"
|
||||
value={includePopular}
|
||||
helpText={translate('IncludePopularMoviesHelpText')}
|
||||
onChange={this.onChangeOption}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('PosterSize')}</FormLabel>
|
||||
|
||||
@@ -172,6 +210,8 @@ DiscoverMoviePosterOptionsModalContent.propTypes = {
|
||||
size: PropTypes.string.isRequired,
|
||||
showTitle: PropTypes.bool.isRequired,
|
||||
includeRecommendations: PropTypes.bool.isRequired,
|
||||
includeTrending: PropTypes.bool.isRequired,
|
||||
includePopular: PropTypes.bool.isRequired,
|
||||
onChangePosterOption: PropTypes.func.isRequired,
|
||||
onChangeOption: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
|
||||
@@ -15,17 +15,29 @@ class DiscoverMovieTableOptions extends Component {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
includeRecommendations: props.includeRecommendations
|
||||
includeRecommendations: props.includeRecommendations,
|
||||
includeTrending: props.includeTrending,
|
||||
includePopular: props.includePopular
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { includeRecommendations } = this.props;
|
||||
const {
|
||||
includeRecommendations,
|
||||
includeTrending,
|
||||
includePopular
|
||||
} = this.props;
|
||||
|
||||
if (includeRecommendations !== prevProps.includeRecommendations) {
|
||||
this.setState({
|
||||
includeRecommendations
|
||||
});
|
||||
this.setState({ includeRecommendations });
|
||||
}
|
||||
|
||||
if (includeTrending !== prevProps.includeTrending) {
|
||||
this.setState({ includeTrending });
|
||||
}
|
||||
|
||||
if (includePopular !== prevProps.includePopular) {
|
||||
this.setState({ includePopular });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,27 +59,57 @@ class DiscoverMovieTableOptions extends Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
includeRecommendations
|
||||
includeRecommendations,
|
||||
includeTrending,
|
||||
includePopular
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeRadarrRecommendations')}</FormLabel>
|
||||
<>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeRadarrRecommendations')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includeRecommendations"
|
||||
value={includeRecommendations}
|
||||
helpText={translate('IncludeRecommendationsHelpText')}
|
||||
onChange={this.onChangeOption}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includeRecommendations"
|
||||
value={includeRecommendations}
|
||||
helpText={translate('IncludeRecommendationsHelpText')}
|
||||
onChange={this.onChangeOption}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeTrending')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includeTrending"
|
||||
value={includeTrending}
|
||||
helpText={translate('IncludeTrendingMoviesHelpText')}
|
||||
onChange={this.onChangeOption}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludePopular')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includePopular"
|
||||
value={includePopular}
|
||||
helpText={translate('IncludePopularMoviesHelpText')}
|
||||
onChange={this.onChangeOption}
|
||||
/>
|
||||
</FormGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DiscoverMovieTableOptions.propTypes = {
|
||||
includeRecommendations: PropTypes.bool.isRequired,
|
||||
includeTrending: PropTypes.bool.isRequired,
|
||||
includePopular: PropTypes.bool.isRequired,
|
||||
onChangeOption: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import { getMovieStatusDetails } from 'Movie/MovieStatus';
|
||||
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
|
||||
import styles from './ListMovieStatusCell.css';
|
||||
|
||||
function ListMovieStatusCell(props) {
|
||||
|
||||
@@ -104,7 +104,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (allowMovieChange && movie && quality && languages) {
|
||||
if (allowMovieChange && movie && quality && languages && size > 0) {
|
||||
onSelectedChange({
|
||||
id,
|
||||
hasMovieFileId: !!movieFileId,
|
||||
|
||||
@@ -6,7 +6,17 @@
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.folderPath {
|
||||
font-weight: bold;
|
||||
font-family: var(--defaultFontFamily);
|
||||
}
|
||||
|
||||
.deleteFilesMessage {
|
||||
margin-top: 20px;
|
||||
color: var(--dangerColor);
|
||||
|
||||
.deleteCount {
|
||||
margin-top: 20px;
|
||||
color: var(--warningColor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'deleteCount': string;
|
||||
'deleteFilesMessage': string;
|
||||
'folderPath': string;
|
||||
'pathContainer': string;
|
||||
'pathIcon': string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
@@ -49,34 +50,26 @@ class DeleteMovieModalContent extends Component {
|
||||
const {
|
||||
title,
|
||||
path,
|
||||
hasFile,
|
||||
statistics,
|
||||
statistics = {},
|
||||
deleteOptions,
|
||||
onModalClose,
|
||||
onDeleteOptionChange
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
movieFileCount = 0,
|
||||
sizeOnDisk = 0
|
||||
} = statistics;
|
||||
|
||||
const deleteFiles = this.state.deleteFiles;
|
||||
const addImportExclusion = deleteOptions.addImportExclusion;
|
||||
|
||||
let deleteFilesLabel = hasFile ? translate('DeleteFileLabel', [1]) : translate('DeleteFilesLabel', [0]);
|
||||
let deleteFilesHelpText = translate('DeleteFilesHelpText');
|
||||
|
||||
if (!hasFile) {
|
||||
deleteFilesLabel = translate('DeleteMovieFolderLabel');
|
||||
deleteFilesHelpText = translate('DeleteMovieFolderHelpText');
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalHeader>
|
||||
{translate('DeleteHeader', [title])}
|
||||
{translate('DeleteHeader', { title })}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
@@ -105,32 +98,32 @@ class DeleteMovieModalContent extends Component {
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{deleteFilesLabel}</FormLabel>
|
||||
<FormLabel>{movieFileCount === 0 ? translate('DeleteMovieFolder') : translate('DeleteMovieFiles', { movieFileCount })}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="deleteFiles"
|
||||
value={deleteFiles}
|
||||
helpText={deleteFilesHelpText}
|
||||
helpText={movieFileCount === 0 ? translate('DeleteMovieFolderHelpText') : translate('DeleteMovieFilesHelpText')}
|
||||
kind={kinds.DANGER}
|
||||
onChange={this.onDeleteFilesChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
deleteFiles &&
|
||||
deleteFiles ?
|
||||
<div className={styles.deleteFilesMessage}>
|
||||
<div>
|
||||
{translate('DeleteTheMovieFolder', { path })}
|
||||
</div>
|
||||
<div><InlineMarkdown data={translate('DeleteMovieFolderConfirmation', { path })} blockClassName={styles.folderPath} /></div>
|
||||
|
||||
{
|
||||
!!hasFile &&
|
||||
<div>
|
||||
{hasFile} {translate('MovieFilesTotaling')} {formatBytes(sizeOnDisk)}
|
||||
</div>
|
||||
movieFileCount ?
|
||||
<div className={styles.deleteCount}>
|
||||
{translate('DeleteMovieFolderMovieCount', { movieFileCount, size: formatBytes(sizeOnDisk) })}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
</ModalBody>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import TextTruncate from 'react-text-truncate';
|
||||
@@ -51,7 +50,8 @@ const defaultFontSize = parseInt(fonts.defaultFontSize);
|
||||
const lineHeight = parseFloat(fonts.lineHeight);
|
||||
|
||||
function getFanartUrl(images) {
|
||||
return _.find(images, { coverType: 'fanart' })?.url;
|
||||
const image = images.find((img) => img.coverType === 'fanart');
|
||||
return image?.url ?? image?.remoteUrl;
|
||||
}
|
||||
|
||||
class MovieDetails extends Component {
|
||||
@@ -285,8 +285,10 @@ class MovieDetails extends Component {
|
||||
const fanartUrl = getFanartUrl(images);
|
||||
const marqueeWidth = isSmallScreen ? titleWidth : (titleWidth - 150);
|
||||
|
||||
const pageTitle = `${title}${year > 0 ? ` (${year})` : ''}`;
|
||||
|
||||
return (
|
||||
<PageContent title={title}>
|
||||
<PageContent title={pageTitle}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
|
||||
@@ -8,18 +8,29 @@ import translate from 'Utilities/String/translate';
|
||||
import styles from './MovieReleaseDates.css';
|
||||
|
||||
interface MovieReleaseDatesProps {
|
||||
inCinemas: string;
|
||||
physicalRelease: string;
|
||||
digitalRelease: string;
|
||||
inCinemas?: string;
|
||||
digitalRelease?: string;
|
||||
physicalRelease?: string;
|
||||
}
|
||||
|
||||
function MovieReleaseDates(props: MovieReleaseDatesProps) {
|
||||
const { inCinemas, physicalRelease, digitalRelease } = props;
|
||||
const { inCinemas, digitalRelease, physicalRelease } = props;
|
||||
|
||||
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
if (!inCinemas && !physicalRelease && !digitalRelease) {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.dateIcon}>
|
||||
<Icon name={icons.MISSING} />
|
||||
</div>
|
||||
{translate('NoMovieReleaseDatesAvailable')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{inCinemas ? (
|
||||
|
||||
@@ -15,7 +15,7 @@ function MovieHistoryModal(props) {
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
size={sizes.EXTRA_LARGE}
|
||||
size={sizes.EXTRA_EXTRA_LARGE}
|
||||
>
|
||||
<MovieHistoryModalContentConnector
|
||||
{...otherProps}
|
||||
|
||||
@@ -4,13 +4,13 @@ import HistoryDetailsModal from 'Activity/History/Details/HistoryDetailsModal';
|
||||
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import MovieFormats from 'Movie/MovieFormats';
|
||||
import MovieLanguage from 'Movie/MovieLanguage';
|
||||
import MovieQuality from 'Movie/MovieQuality';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './MovieHistoryRow.css';
|
||||
@@ -109,9 +109,9 @@ class MovieHistoryRow extends Component {
|
||||
{formatCustomFormatScore(customFormatScore, customFormats.length)}
|
||||
</TableRowCell>
|
||||
|
||||
<RelativeDateCellConnector
|
||||
date={date}
|
||||
/>
|
||||
<TableRowCell>
|
||||
{formatDateTime(date, shortDateFormat, timeFormat, { includeSeconds: true })}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.actions}>
|
||||
<IconButton
|
||||
|
||||
@@ -10,4 +10,15 @@
|
||||
.path {
|
||||
margin-left: 5px;
|
||||
color: var(--dangerColor);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.statistics {
|
||||
margin-left: 5px;
|
||||
color: var(--warningColor);
|
||||
}
|
||||
|
||||
.deleteFilesMessage {
|
||||
margin-top: 20px;
|
||||
color: var(--warningColor);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'deleteFilesMessage': string;
|
||||
'message': string;
|
||||
'path': string;
|
||||
'pathContainer': string;
|
||||
'statistics': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -16,6 +16,7 @@ import Movie from 'Movie/Movie';
|
||||
import { bulkDeleteMovie, setDeleteOption } from 'Store/Actions/movieActions';
|
||||
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './DeleteMovieModalContent.css';
|
||||
|
||||
@@ -85,9 +86,31 @@ function DeleteMovieModalContent(props: DeleteMovieModalContentProps) {
|
||||
onModalClose,
|
||||
]);
|
||||
|
||||
const { totalMovieFileCount, totalSizeOnDisk } = useMemo(() => {
|
||||
return movies.reduce(
|
||||
(acc, m) => {
|
||||
const { statistics = { movieFileCount: 0, sizeOnDisk: 0 } } = m;
|
||||
const { movieFileCount = 0, sizeOnDisk = 0 } = statistics;
|
||||
|
||||
acc.totalMovieFileCount += movieFileCount;
|
||||
acc.totalSizeOnDisk += sizeOnDisk;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
totalMovieFileCount: 0,
|
||||
totalSizeOnDisk: 0,
|
||||
}
|
||||
);
|
||||
}, [movies]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('DeleteSelectedMovie')}</ModalHeader>
|
||||
<ModalHeader>
|
||||
{movies.length > 1
|
||||
? translate('DeleteSelectedMovies')
|
||||
: translate('DeleteSelectedMovie')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div>
|
||||
@@ -104,17 +127,21 @@ function DeleteMovieModalContent(props: DeleteMovieModalContentProps) {
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{`Delete Movie Folder${
|
||||
movies.length > 1 ? 's' : ''
|
||||
}`}</FormLabel>
|
||||
<FormLabel>
|
||||
{movies.length > 1
|
||||
? translate('DeleteMovieFolders')
|
||||
: translate('DeleteMovieFolder')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="deleteFiles"
|
||||
value={deleteFiles}
|
||||
helpText={`Delete Movie Folder${
|
||||
movies.length > 1 ? 's' : ''
|
||||
} and all contents`}
|
||||
helpText={
|
||||
movies.length > 1
|
||||
? translate('DeleteMovieFoldersHelpText')
|
||||
: translate('DeleteMovieFolderHelpText')
|
||||
}
|
||||
kind={kinds.DANGER}
|
||||
onChange={onDeleteFilesChange}
|
||||
/>
|
||||
@@ -122,26 +149,54 @@ function DeleteMovieModalContent(props: DeleteMovieModalContentProps) {
|
||||
</div>
|
||||
|
||||
<div className={styles.message}>
|
||||
{`Are you sure you want to delete ${movies.length} selected movie(s)${
|
||||
deleteFiles ? ' and all contents' : ''
|
||||
}?`}
|
||||
{deleteFiles
|
||||
? translate('DeleteMovieFolderCountWithFilesConfirmation', {
|
||||
count: movies.length,
|
||||
})
|
||||
: translate('DeleteMovieFolderCountConfirmation', {
|
||||
count: movies.length,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{movies.map((s) => {
|
||||
{movies.map((m) => {
|
||||
const { movieFileCount = 0, sizeOnDisk = 0 } = m.statistics;
|
||||
|
||||
return (
|
||||
<li key={s.title}>
|
||||
<span>{s.title}</span>
|
||||
<li key={m.title}>
|
||||
<span>{m.title}</span>
|
||||
|
||||
{deleteFiles && (
|
||||
<span className={styles.pathContainer}>
|
||||
-<span className={styles.path}>{s.path}</span>
|
||||
<span>
|
||||
<span className={styles.pathContainer}>
|
||||
-<span className={styles.path}>{m.path}</span>
|
||||
</span>
|
||||
|
||||
{!!movieFileCount && (
|
||||
<span className={styles.statistics}>
|
||||
(
|
||||
{translate('DeleteMovieFolderMovieCount', {
|
||||
movieFileCount,
|
||||
size: formatBytes(sizeOnDisk),
|
||||
})}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{deleteFiles && !!totalMovieFileCount ? (
|
||||
<div className={styles.deleteFilesMessage}>
|
||||
{translate('DeleteMovieFolderMovieCount', {
|
||||
movieFileCount: totalMovieFileCount,
|
||||
size: formatBytes(totalSizeOnDisk),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Icon from 'Components/Icon';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { getMovieStatusDetails } from 'Movie/MovieStatus';
|
||||
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
|
||||
import { toggleMovieMonitored } from 'Store/Actions/movieActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './MovieStatusCell.css';
|
||||
|
||||
6
frontend/src/Movie/MovieSearchCell.css
Normal file
6
frontend/src/Movie/MovieSearchCell.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.movieSearchCell {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 70px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
7
frontend/src/Movie/MovieSearchCell.css.d.ts
vendored
Normal file
7
frontend/src/Movie/MovieSearchCell.css.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'movieSearchCell': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
81
frontend/src/Movie/MovieSearchCell.js
Normal file
81
frontend/src/Movie/MovieSearchCell.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import MovieInteractiveSearchModalConnector from './Search/MovieInteractiveSearchModalConnector';
|
||||
import styles from './MovieSearchCell.css';
|
||||
|
||||
class MovieSearchCell extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isInteractiveSearchModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onManualSearchPress = () => {
|
||||
this.setState({ isInteractiveSearchModalOpen: true });
|
||||
};
|
||||
|
||||
onInteractiveSearchModalClose = () => {
|
||||
this.setState({ isInteractiveSearchModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
movieId,
|
||||
movieTitle,
|
||||
isSearching,
|
||||
onSearchPress,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.movieSearchCell}>
|
||||
<SpinnerIconButton
|
||||
name={icons.SEARCH}
|
||||
isSpinning={isSearching}
|
||||
onPress={onSearchPress}
|
||||
title={translate('AutomaticSearch')}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
name={icons.INTERACTIVE}
|
||||
onPress={this.onManualSearchPress}
|
||||
title={translate('InteractiveSearch')}
|
||||
/>
|
||||
|
||||
<MovieInteractiveSearchModalConnector
|
||||
isOpen={this.state.isInteractiveSearchModalOpen}
|
||||
movieId={movieId}
|
||||
movieTitle={movieTitle}
|
||||
onModalClose={this.onInteractiveSearchModalClose}
|
||||
{...otherProps}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MovieSearchCell.propTypes = {
|
||||
movieId: PropTypes.number.isRequired,
|
||||
movieTitle: PropTypes.string.isRequired,
|
||||
isSearching: PropTypes.bool.isRequired,
|
||||
onSearchPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default MovieSearchCell;
|
||||
48
frontend/src/Movie/MovieSearchCellConnector.js
Normal file
48
frontend/src/Movie/MovieSearchCellConnector.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import MovieSearchCell from 'Movie/MovieSearchCell';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createMovieSelector from 'Store/Selectors/createMovieSelector';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { movieId }) => movieId,
|
||||
createMovieSelector(),
|
||||
createCommandsSelector(),
|
||||
(movieId, movie, commands) => {
|
||||
const isSearching = commands.some((command) => {
|
||||
const movieSearch = command.name === commandNames.MOVIE_SEARCH;
|
||||
|
||||
if (!movieSearch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isCommandExecuting(command) &&
|
||||
command.body.movieIds.indexOf(movieId) > -1
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
movieMonitored: movie.monitored,
|
||||
isSearching
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onSearchPress(name, path) {
|
||||
dispatch(executeCommand({
|
||||
name: commandNames.MOVIE_SEARCH,
|
||||
movieIds: [props.movieId]
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(MovieSearchCell);
|
||||
4
frontend/src/Movie/MovieStatus.css
Normal file
4
frontend/src/Movie/MovieStatus.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'input': string;
|
||||
'center': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -1,33 +1,115 @@
|
||||
import { icons } from 'Helpers/Props';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||
import Icon from 'Components/Icon';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import MovieQuality from './MovieQuality';
|
||||
import styles from './MovieStatus.css';
|
||||
|
||||
export function getMovieStatusDetails(status) {
|
||||
function MovieStatus(props) {
|
||||
const {
|
||||
isAvailable,
|
||||
monitored,
|
||||
grabbed,
|
||||
queueItem,
|
||||
movieFile
|
||||
} = props;
|
||||
|
||||
let statusDetails = {
|
||||
icon: icons.ANNOUNCED,
|
||||
title: translate('Announced'),
|
||||
message: translate('AnnouncedMsg')
|
||||
};
|
||||
const hasMovieFile = !!movieFile;
|
||||
const isQueued = !!queueItem;
|
||||
|
||||
if (status === 'deleted') {
|
||||
statusDetails = {
|
||||
icon: icons.MOVIE_DELETED,
|
||||
title: translate('Deleted'),
|
||||
message: translate('DeletedMsg')
|
||||
};
|
||||
} else if (status === 'inCinemas') {
|
||||
statusDetails = {
|
||||
icon: icons.IN_CINEMAS,
|
||||
title: translate('InCinemas'),
|
||||
message: translate('InCinemasMsg')
|
||||
};
|
||||
} else if (status === 'released') {
|
||||
statusDetails = {
|
||||
icon: icons.MOVIE_FILE,
|
||||
title: translate('Released'),
|
||||
message: translate('ReleasedMsg')
|
||||
};
|
||||
if (isQueued) {
|
||||
const {
|
||||
sizeleft,
|
||||
size
|
||||
} = queueItem;
|
||||
|
||||
const progress = size ? (100 - sizeleft / size * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<QueueDetails
|
||||
{...queueItem}
|
||||
progressBar={
|
||||
<ProgressBar
|
||||
progress={progress}
|
||||
kind={kinds.PURPLE}
|
||||
size={sizes.MEDIUM}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return statusDetails;
|
||||
if (grabbed) {
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('MovieIsDownloading')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasMovieFile) {
|
||||
const quality = movieFile.quality;
|
||||
const isCutoffNotMet = movieFile.qualityCutoffNotMet;
|
||||
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<MovieQuality
|
||||
quality={quality}
|
||||
size={movieFile.size}
|
||||
isCutoffNotMet={isCutoffNotMet}
|
||||
title={translate('MovieDownloaded')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!monitored) {
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.UNMONITORED}
|
||||
kind={kinds.DISABLED}
|
||||
title={translate('MovieIsNotMonitored')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAvailable) {
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.MISSING}
|
||||
title={translate('MovieMissingFromDisk')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.NOT_AIRED}
|
||||
title={translate('MovieIsNotAvailable')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MovieStatus.propTypes = {
|
||||
isAvailable: PropTypes.bool.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
grabbed: PropTypes.bool,
|
||||
queueItem: PropTypes.object,
|
||||
movieFile: PropTypes.object
|
||||
};
|
||||
|
||||
export default MovieStatus;
|
||||
|
||||
50
frontend/src/Movie/MovieStatusConnector.js
Normal file
50
frontend/src/Movie/MovieStatusConnector.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import MovieStatus from 'Movie/MovieStatus';
|
||||
import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
|
||||
import { createMovieByEntitySelector } from 'Store/Selectors/createMovieSelector';
|
||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createMovieByEntitySelector(),
|
||||
createQueueItemSelector(),
|
||||
createMovieFileSelector(),
|
||||
(movie, queueItem, movieFile) => {
|
||||
const result = _.pick(movie, [
|
||||
'isAvailable',
|
||||
'monitored',
|
||||
'grabbed'
|
||||
]);
|
||||
|
||||
result.queueItem = queueItem;
|
||||
result.movieFile = movieFile;
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
class MovieStatusConnector extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MovieStatus
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MovieStatusConnector.propTypes = {
|
||||
movieId: PropTypes.number.isRequired,
|
||||
movieFileId: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, null)(MovieStatusConnector);
|
||||
@@ -8,6 +8,7 @@ function MovieInteractiveSearchModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
movieId,
|
||||
movieTitle,
|
||||
onModalClose
|
||||
} = props;
|
||||
|
||||
@@ -20,6 +21,7 @@ function MovieInteractiveSearchModal(props) {
|
||||
>
|
||||
<MovieInteractiveSearchModalContent
|
||||
movieId={movieId}
|
||||
movieTitle={movieTitle}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
@@ -29,6 +31,7 @@ function MovieInteractiveSearchModal(props) {
|
||||
MovieInteractiveSearchModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
movieId: PropTypes.number.isRequired,
|
||||
movieTitle: PropTypes.string,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
||||
@@ -12,13 +12,17 @@ import translate from 'Utilities/String/translate';
|
||||
function MovieInteractiveSearchModalContent(props) {
|
||||
const {
|
||||
movieId,
|
||||
movieTitle,
|
||||
onModalClose
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('InteractiveSearchModalHeader')}
|
||||
{movieTitle === undefined ?
|
||||
translate('InteractiveSearchModalHeader') :
|
||||
translate('InteractiveSearchModalHeaderTitle', { title: movieTitle })
|
||||
}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody scrollDirection={scrollDirections.BOTH}>
|
||||
@@ -38,6 +42,7 @@ function MovieInteractiveSearchModalContent(props) {
|
||||
|
||||
MovieInteractiveSearchModalContent.propTypes = {
|
||||
movieId: PropTypes.number.isRequired,
|
||||
movieTitle: PropTypes.string,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
||||
32
frontend/src/Movie/getMovieStatusDetails.js
Normal file
32
frontend/src/Movie/getMovieStatusDetails.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export default function getMovieStatusDetails(status) {
|
||||
let statusDetails = {
|
||||
icon: icons.ANNOUNCED,
|
||||
title: translate('Announced'),
|
||||
message: translate('AnnouncedMsg')
|
||||
};
|
||||
|
||||
if (status === 'deleted') {
|
||||
statusDetails = {
|
||||
icon: icons.MOVIE_DELETED,
|
||||
title: translate('Deleted'),
|
||||
message: translate('DeletedMsg')
|
||||
};
|
||||
} else if (status === 'inCinemas') {
|
||||
statusDetails = {
|
||||
icon: icons.IN_CINEMAS,
|
||||
title: translate('InCinemas'),
|
||||
message: translate('InCinemasMsg')
|
||||
};
|
||||
} else if (status === 'released') {
|
||||
statusDetails = {
|
||||
icon: icons.MOVIE_FILE,
|
||||
title: translate('Released'),
|
||||
message: translate('ReleasedMsg')
|
||||
};
|
||||
}
|
||||
|
||||
return statusDetails;
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
export const CALENDAR = 'calendar';
|
||||
export const MOVIES = 'movies';
|
||||
export const INTERACTIVE_IMPORT = 'interactiveImport.movies';
|
||||
export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet';
|
||||
export const WANTED_MISSING = 'wanted.missing';
|
||||
|
||||
export default {
|
||||
CALENDAR,
|
||||
MOVIES,
|
||||
INTERACTIVE_IMPORT
|
||||
INTERACTIVE_IMPORT,
|
||||
WANTED_CUTOFF_UNMET,
|
||||
WANTED_MISSING
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ function createMapStateToProps() {
|
||||
createMovieFileSelector(),
|
||||
(movieFile) => {
|
||||
return {
|
||||
language: movieFile ? movieFile.language : undefined
|
||||
languages: movieFile ? movieFile.languages : undefined
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -72,15 +72,15 @@ const fileNameTokens = [
|
||||
];
|
||||
|
||||
const movieTokens = [
|
||||
{ token: '{Movie Title}', example: 'Movie\'s Title' },
|
||||
{ token: '{Movie Title:DE}', example: 'Titel des Films' },
|
||||
{ token: '{Movie CleanTitle}', example: 'Movies Title' },
|
||||
{ token: '{Movie TitleThe}', example: 'Movie\'s Title, The' },
|
||||
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας' },
|
||||
{ token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας' },
|
||||
{ token: '{Movie Title}', example: 'Movie\'s Title', footNote: 1 },
|
||||
{ token: '{Movie Title:DE}', example: 'Titel des Films', footNote: 1 },
|
||||
{ token: '{Movie CleanTitle}', example: 'Movies Title', footNote: 1 },
|
||||
{ token: '{Movie TitleThe}', example: 'Movie\'s Title, The', footNote: 1 },
|
||||
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
|
||||
{ token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
|
||||
{ token: '{Movie TitleFirstCharacter}', example: 'M' },
|
||||
{ token: '{Movie TitleFirstCharacter:DE}', example: 'T' },
|
||||
{ token: '{Movie Collection}', example: 'The Movie Collection' },
|
||||
{ token: '{Movie Collection}', example: 'The Movie Collection', footNote: 1 },
|
||||
{ token: '{Movie Certification}', example: 'R' },
|
||||
{ token: '{Release Year}', example: '2009' }
|
||||
];
|
||||
@@ -112,11 +112,11 @@ const mediaInfoTokens = [
|
||||
];
|
||||
|
||||
const releaseGroupTokens = [
|
||||
{ token: '{Release Group}', example: 'Rls Grp' }
|
||||
{ token: '{Release Group}', example: 'Rls Grp', footNote: 1 }
|
||||
];
|
||||
|
||||
const editionTokens = [
|
||||
{ token: '{Edition Tags}', example: 'IMAX' }
|
||||
{ token: '{Edition Tags}', example: 'IMAX', footNote: 1 }
|
||||
];
|
||||
|
||||
const customFormatTokens = [
|
||||
@@ -268,7 +268,7 @@ class NamingModal extends Component {
|
||||
<FieldSet legend={translate('Movie')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
movieTokens.map(({ token, example }) => {
|
||||
movieTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
@@ -276,6 +276,7 @@ class NamingModal extends Component {
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
@@ -285,6 +286,11 @@ class NamingModal extends Component {
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('MovieFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('MovieID')}>
|
||||
@@ -365,7 +371,7 @@ class NamingModal extends Component {
|
||||
<FieldSet legend={translate('ReleaseGroup')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
releaseGroupTokens.map(({ token, example }) => {
|
||||
releaseGroupTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
@@ -373,6 +379,7 @@ class NamingModal extends Component {
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
@@ -382,12 +389,17 @@ class NamingModal extends Component {
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Edition')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
editionTokens.map(({ token, example }) => {
|
||||
editionTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
@@ -395,6 +407,7 @@ class NamingModal extends Component {
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
@@ -404,6 +417,11 @@ class NamingModal extends Component {
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('EditionFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('CustomFormats')}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import {
|
||||
saveNotification,
|
||||
setNotificationFieldValue,
|
||||
setNotificationFieldValues,
|
||||
setNotificationValue,
|
||||
testNotification,
|
||||
toggleAdvancedSettings
|
||||
@@ -27,7 +27,7 @@ function createMapStateToProps() {
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setNotificationValue,
|
||||
setNotificationFieldValue,
|
||||
setNotificationFieldValues,
|
||||
saveNotification,
|
||||
testNotification,
|
||||
toggleAdvancedSettings
|
||||
@@ -51,8 +51,8 @@ class EditNotificationModalContentConnector extends Component {
|
||||
this.props.setNotificationValue({ name, value });
|
||||
};
|
||||
|
||||
onFieldChange = ({ name, value }) => {
|
||||
this.props.setNotificationFieldValue({ name, value });
|
||||
onFieldChange = ({ name, value, additionalProperties = {} }) => {
|
||||
this.props.setNotificationFieldValues({ properties: { ...additionalProperties, [name]: value } });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
@@ -91,7 +91,7 @@ EditNotificationModalContentConnector.propTypes = {
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
setNotificationValue: PropTypes.func.isRequired,
|
||||
setNotificationFieldValue: PropTypes.func.isRequired,
|
||||
setNotificationFieldValues: PropTypes.func.isRequired,
|
||||
saveNotification: PropTypes.func.isRequired,
|
||||
testNotification: PropTypes.func.isRequired,
|
||||
toggleAdvancedSettings: PropTypes.func.isRequired,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||
|
||||
function createSetProviderFieldValuesReducer(section) {
|
||||
return (state, { payload }) => {
|
||||
if (section === payload.section) {
|
||||
const { properties } = payload;
|
||||
const newState = getSectionState(state, section);
|
||||
newState.pendingChanges = Object.assign({}, newState.pendingChanges);
|
||||
const fields = Object.assign({}, newState.pendingChanges.fields || {});
|
||||
|
||||
Object.keys(properties).forEach((name) => {
|
||||
fields[name] = properties[name];
|
||||
});
|
||||
|
||||
newState.pendingChanges.fields = fields;
|
||||
|
||||
return updateSectionState(state, section, newState);
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
}
|
||||
|
||||
export default createSetProviderFieldValuesReducer;
|
||||
@@ -1,29 +1,29 @@
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import updateEpisodes from 'Utilities/Episode/updateEpisodes';
|
||||
import updateMovies from 'Utilities/Movie/updateMovies';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
|
||||
function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) {
|
||||
function createBatchToggleMovieMonitoredHandler(section, fetchHandler) {
|
||||
return function(getState, payload, dispatch) {
|
||||
const {
|
||||
episodeIds,
|
||||
movieIds,
|
||||
monitored
|
||||
} = payload;
|
||||
|
||||
const state = getSectionState(getState(), section, true);
|
||||
|
||||
dispatch(updateEpisodes(section, state.items, episodeIds, {
|
||||
dispatch(updateMovies(section, state.items, movieIds, {
|
||||
isSaving: true
|
||||
}));
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: '/episode/monitor',
|
||||
url: '/movie/editor',
|
||||
method: 'PUT',
|
||||
data: JSON.stringify({ episodeIds, monitored }),
|
||||
data: JSON.stringify({ movieIds, monitored }),
|
||||
dataType: 'json'
|
||||
}).request;
|
||||
|
||||
promise.done(() => {
|
||||
dispatch(updateEpisodes(section, state.items, episodeIds, {
|
||||
dispatch(updateMovies(section, state.items, movieIds, {
|
||||
isSaving: false,
|
||||
monitored
|
||||
}));
|
||||
@@ -32,11 +32,11 @@ function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) {
|
||||
});
|
||||
|
||||
promise.fail(() => {
|
||||
dispatch(updateEpisodes(section, state.items, episodeIds, {
|
||||
dispatch(updateMovies(section, state.items, movieIds, {
|
||||
isSaving: false
|
||||
}));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default createBatchToggleEpisodeMonitoredHandler;
|
||||
export default createBatchToggleMovieMonitoredHandler;
|
||||
@@ -5,6 +5,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand
|
||||
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
|
||||
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
|
||||
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
|
||||
import createSetProviderFieldValuesReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValuesReducer';
|
||||
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||
import { createThunk } from 'Store/thunks';
|
||||
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
|
||||
@@ -22,6 +23,7 @@ export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificati
|
||||
export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema';
|
||||
export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue';
|
||||
export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue';
|
||||
export const SET_NOTIFICATION_FIELD_VALUES = 'settings/notifications/setNotificationFieldValues';
|
||||
export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification';
|
||||
export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification';
|
||||
export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification';
|
||||
@@ -55,6 +57,13 @@ export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VAL
|
||||
};
|
||||
});
|
||||
|
||||
export const setNotificationFieldValues = createAction(SET_NOTIFICATION_FIELD_VALUES, (payload) => {
|
||||
return {
|
||||
section,
|
||||
...payload
|
||||
};
|
||||
});
|
||||
|
||||
//
|
||||
// Details
|
||||
|
||||
@@ -99,6 +108,7 @@ export default {
|
||||
reducers: {
|
||||
[SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section),
|
||||
[SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
|
||||
[SET_NOTIFICATION_FIELD_VALUES]: createSetProviderFieldValuesReducer(section),
|
||||
|
||||
[SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => {
|
||||
return selectProviderSchema(state, section, payload, (selectedSchema) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createAction } from 'redux-actions';
|
||||
import { batchActions } from 'redux-batched-actions';
|
||||
import { sortDirections } from 'Helpers/Props';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
|
||||
@@ -79,6 +79,31 @@ export const defaultState = {
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
}
|
||||
],
|
||||
|
||||
selectedFilterKey: 'all',
|
||||
|
||||
filters: [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: []
|
||||
}
|
||||
],
|
||||
|
||||
filterBuilderProps: [
|
||||
{
|
||||
name: 'movieIds',
|
||||
label: () => translate('Movie'),
|
||||
type: filterBuilderTypes.EQUAL,
|
||||
valueType: filterBuilderValueTypes.MOVIE
|
||||
},
|
||||
{
|
||||
name: 'protocols',
|
||||
label: () => translate('Protocol'),
|
||||
type: filterBuilderTypes.EQUAL,
|
||||
valueType: filterBuilderValueTypes.PROTOCOL
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -86,6 +111,7 @@ export const persistState = [
|
||||
'blocklist.pageSize',
|
||||
'blocklist.sortKey',
|
||||
'blocklist.sortDirection',
|
||||
'blocklist.selectedFilterKey',
|
||||
'blocklist.columns'
|
||||
];
|
||||
|
||||
@@ -99,6 +125,7 @@ export const GOTO_NEXT_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistNextPage';
|
||||
export const GOTO_LAST_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistLastPage';
|
||||
export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage';
|
||||
export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort';
|
||||
export const SET_BLOCKLIST_FILTER = 'blocklist/setBlocklistFilter';
|
||||
export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption';
|
||||
export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem';
|
||||
export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems';
|
||||
@@ -114,6 +141,7 @@ export const gotoBlocklistNextPage = createThunk(GOTO_NEXT_BLOCKLIST_PAGE);
|
||||
export const gotoBlocklistLastPage = createThunk(GOTO_LAST_BLOCKLIST_PAGE);
|
||||
export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE);
|
||||
export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT);
|
||||
export const setBlocklistFilter = createThunk(SET_BLOCKLIST_FILTER);
|
||||
export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION);
|
||||
export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM);
|
||||
export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS);
|
||||
@@ -134,7 +162,8 @@ export const actionHandlers = handleThunks({
|
||||
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLOCKLIST_PAGE,
|
||||
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLOCKLIST_PAGE,
|
||||
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLOCKLIST_PAGE,
|
||||
[serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT
|
||||
[serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT,
|
||||
[serverSideCollectionHandlers.FILTER]: SET_BLOCKLIST_FILTER
|
||||
}),
|
||||
|
||||
[REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'),
|
||||
|
||||
@@ -42,7 +42,9 @@ export const defaultState = {
|
||||
view: 'overview',
|
||||
|
||||
options: {
|
||||
includeRecommendations: true
|
||||
includeRecommendations: true,
|
||||
includeTrending: true,
|
||||
includePopular: true
|
||||
},
|
||||
|
||||
defaults: {
|
||||
@@ -583,10 +585,14 @@ export const actionHandlers = handleThunks({
|
||||
...otherPayload
|
||||
} = payload;
|
||||
|
||||
const includeRecommendations = getState().discoverMovie.options.includeRecommendations;
|
||||
const {
|
||||
includeRecommendations = false,
|
||||
includeTrending = false,
|
||||
includePopular = false
|
||||
} = getState().discoverMovie.options;
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: `/importlist/movie?includeRecommendations=${includeRecommendations}`,
|
||||
url: `/importlist/movie?includeRecommendations=${includeRecommendations}&includeTrending=${includeTrending}&includePopular=${includePopular}`,
|
||||
data: otherPayload,
|
||||
traditional: true
|
||||
}).request;
|
||||
|
||||
@@ -28,6 +28,7 @@ import * as rootFolders from './rootFolderActions';
|
||||
import * as settings from './settingsActions';
|
||||
import * as system from './systemActions';
|
||||
import * as tags from './tagActions';
|
||||
import * as wanted from './wantedActions';
|
||||
|
||||
export default [
|
||||
addMovie,
|
||||
@@ -59,5 +60,6 @@ export default [
|
||||
movieCredits,
|
||||
settings,
|
||||
system,
|
||||
tags
|
||||
tags,
|
||||
wanted
|
||||
];
|
||||
|
||||
334
frontend/src/Store/Actions/wantedActions.js
Normal file
334
frontend/src/Store/Actions/wantedActions.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import { createAction } from 'redux-actions';
|
||||
import { filterTypes, sortDirections } from 'Helpers/Props';
|
||||
import createBatchToggleMovieMonitoredHandler from 'Store/Actions/Creators/createBatchToggleMovieMonitoredHandler';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
|
||||
import createClearReducer from './Creators/Reducers/createClearReducer';
|
||||
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
export const section = 'wanted';
|
||||
|
||||
//
|
||||
// State
|
||||
|
||||
export const defaultState = {
|
||||
missing: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
pageSize: 20,
|
||||
sortKey: 'movieMetadata.sortTitle',
|
||||
sortDirection: sortDirections.ASCENDING,
|
||||
error: null,
|
||||
items: [],
|
||||
|
||||
columns: [
|
||||
{
|
||||
name: 'movieMetadata.sortTitle',
|
||||
label: () => translate('MovieTitle'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'movieMetadata.year',
|
||||
label: () => translate('Year'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'movieMetadata.inCinemas',
|
||||
label: () => translate('InCinemas'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'movieMetadata.digitalRelease',
|
||||
label: () => translate('DigitalRelease'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'movieMetadata.physicalRelease',
|
||||
label: () => translate('PhysicalRelease'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: () => translate('Status'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
columnLabel: () => translate('Actions'),
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
}
|
||||
],
|
||||
|
||||
selectedFilterKey: 'monitored',
|
||||
|
||||
filters: [
|
||||
{
|
||||
key: 'monitored',
|
||||
label: () => translate('Monitored'),
|
||||
filters: [
|
||||
{
|
||||
key: 'monitored',
|
||||
value: true,
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'unmonitored',
|
||||
label: () => translate('Unmonitored'),
|
||||
filters: [
|
||||
{
|
||||
key: 'monitored',
|
||||
value: false,
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
cutoffUnmet: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
pageSize: 20,
|
||||
sortKey: 'movieMetadata.sortTitle',
|
||||
sortDirection: sortDirections.ASCENDING,
|
||||
items: [],
|
||||
|
||||
columns: [
|
||||
{
|
||||
name: 'movieMetadata.sortTitle',
|
||||
label: () => translate('MovieTitle'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'movieMetadata.year',
|
||||
label: () => translate('Year'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'movieMetadata.inCinemas',
|
||||
label: () => translate('InCinemas'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'movieMetadata.digitalRelease',
|
||||
label: () => translate('DigitalRelease'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'movieMetadata.physicalRelease',
|
||||
label: () => translate('PhysicalRelease'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: () => translate('Status'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
columnLabel: () => translate('Actions'),
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
}
|
||||
],
|
||||
|
||||
selectedFilterKey: 'monitored',
|
||||
|
||||
filters: [
|
||||
{
|
||||
key: 'monitored',
|
||||
label: () => translate('Monitored'),
|
||||
filters: [
|
||||
{
|
||||
key: 'monitored',
|
||||
value: true,
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'unmonitored',
|
||||
label: () => translate('Unmonitored'),
|
||||
filters: [
|
||||
{
|
||||
key: 'monitored',
|
||||
value: false,
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export const persistState = [
|
||||
'wanted.missing.pageSize',
|
||||
'wanted.missing.sortKey',
|
||||
'wanted.missing.sortDirection',
|
||||
'wanted.missing.selectedFilterKey',
|
||||
'wanted.missing.columns',
|
||||
'wanted.cutoffUnmet.pageSize',
|
||||
'wanted.cutoffUnmet.sortKey',
|
||||
'wanted.cutoffUnmet.sortDirection',
|
||||
'wanted.cutoffUnmet.selectedFilterKey',
|
||||
'wanted.cutoffUnmet.columns'
|
||||
];
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_MISSING = 'wanted/missing/fetchMissing';
|
||||
export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage';
|
||||
export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage';
|
||||
export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage';
|
||||
export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage';
|
||||
export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage';
|
||||
export const SET_MISSING_SORT = 'wanted/missing/setMissingSort';
|
||||
export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter';
|
||||
export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption';
|
||||
export const CLEAR_MISSING = 'wanted/missing/clearMissing';
|
||||
|
||||
export const BATCH_TOGGLE_MISSING_MOVIES = 'wanted/missing/batchToggleMissingMovies';
|
||||
|
||||
export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet';
|
||||
export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage';
|
||||
export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage';
|
||||
export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage';
|
||||
export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage';
|
||||
export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage';
|
||||
export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort';
|
||||
export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter';
|
||||
export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption';
|
||||
export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet';
|
||||
|
||||
export const BATCH_TOGGLE_CUTOFF_UNMET_MOVIES = 'wanted/cutoffUnmet/batchToggleCutoffUnmetMovies';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const fetchMissing = createThunk(FETCH_MISSING);
|
||||
export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE);
|
||||
export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE);
|
||||
export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE);
|
||||
export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE);
|
||||
export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE);
|
||||
export const setMissingSort = createThunk(SET_MISSING_SORT);
|
||||
export const setMissingFilter = createThunk(SET_MISSING_FILTER);
|
||||
export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION);
|
||||
export const clearMissing = createAction(CLEAR_MISSING);
|
||||
|
||||
export const batchToggleMissingMovies = createThunk(BATCH_TOGGLE_MISSING_MOVIES);
|
||||
|
||||
export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET);
|
||||
export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE);
|
||||
export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE);
|
||||
export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE);
|
||||
export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE);
|
||||
export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE);
|
||||
export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT);
|
||||
export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER);
|
||||
export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION);
|
||||
export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET);
|
||||
|
||||
export const batchToggleCutoffUnmetMovies = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_MOVIES);
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
export const actionHandlers = handleThunks({
|
||||
|
||||
...createServerSideCollectionHandlers(
|
||||
'wanted.missing',
|
||||
'/wanted/missing',
|
||||
fetchMissing,
|
||||
{
|
||||
[serverSideCollectionHandlers.FETCH]: FETCH_MISSING,
|
||||
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE,
|
||||
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE,
|
||||
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE,
|
||||
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE,
|
||||
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE,
|
||||
[serverSideCollectionHandlers.SORT]: SET_MISSING_SORT,
|
||||
[serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER
|
||||
}
|
||||
),
|
||||
|
||||
[BATCH_TOGGLE_MISSING_MOVIES]: createBatchToggleMovieMonitoredHandler('wanted.missing', fetchMissing),
|
||||
|
||||
...createServerSideCollectionHandlers(
|
||||
'wanted.cutoffUnmet',
|
||||
'/wanted/cutoff',
|
||||
fetchCutoffUnmet,
|
||||
{
|
||||
[serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET,
|
||||
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE,
|
||||
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE,
|
||||
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE,
|
||||
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE,
|
||||
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE,
|
||||
[serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT,
|
||||
[serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER
|
||||
}
|
||||
),
|
||||
|
||||
[BATCH_TOGGLE_CUTOFF_UNMET_MOVIES]: createBatchToggleMovieMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet)
|
||||
|
||||
});
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
||||
export const reducers = createHandleActions({
|
||||
|
||||
[SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'),
|
||||
[SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'),
|
||||
|
||||
[CLEAR_MISSING]: createClearReducer(
|
||||
'wanted.missing',
|
||||
{
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
totalRecords: 0
|
||||
}
|
||||
),
|
||||
|
||||
[CLEAR_CUTOFF_UNMET]: createClearReducer(
|
||||
'wanted.cutoffUnmet',
|
||||
{
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
totalRecords: 0
|
||||
}
|
||||
)
|
||||
|
||||
}, defaultState, section);
|
||||
@@ -1,4 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
import { createSelector } from 'reselect';
|
||||
import movieEntities from 'Movie/movieEntities';
|
||||
|
||||
export function createMovieSelectorForHook(movieId) {
|
||||
return createSelector(
|
||||
@@ -11,6 +13,16 @@ export function createMovieSelectorForHook(movieId) {
|
||||
);
|
||||
}
|
||||
|
||||
export function createMovieByEntitySelector() {
|
||||
return createSelector(
|
||||
(state, { movieId }) => movieId,
|
||||
(state, { movieEntity = movieEntities.MOVIES }) => _.get(state, movieEntity, { items: [] }),
|
||||
(movieId, movies) => {
|
||||
return _.find(movies.items, { id: movieId });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMovieSelector() {
|
||||
return createSelector(
|
||||
(state, { movieId }) => movieId,
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as dark from './dark';
|
||||
import * as light from './light';
|
||||
|
||||
const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const auto = defaultDark ? { ...dark } : { ...light };
|
||||
const auto = defaultDark ? dark : light;
|
||||
|
||||
export default {
|
||||
auto,
|
||||
|
||||
@@ -2,7 +2,6 @@ module.exports = {
|
||||
// Families
|
||||
defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
|
||||
monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;',
|
||||
passwordFamily: 'text-security-disc',
|
||||
|
||||
// Sizes
|
||||
extraSmallFontSize: '11px',
|
||||
|
||||
@@ -6,6 +6,22 @@ import createMultiMoviesSelector from 'Store/Selectors/createMultiMoviesSelector
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './QueuedTaskRowNameCell.css';
|
||||
|
||||
function formatTitles(titles: string[]) {
|
||||
if (!titles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (titles.length > 11) {
|
||||
return (
|
||||
<span title={titles.join(', ')}>
|
||||
{titles.slice(0, 10).join(', ')}, {titles.length - 10} more
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{titles.join(', ')}</span>;
|
||||
}
|
||||
|
||||
export interface QueuedTaskRowNameCellProps {
|
||||
commandName: string;
|
||||
body: CommandBody;
|
||||
@@ -32,7 +48,7 @@ export default function QueuedTaskRowNameCell(
|
||||
<span className={styles.commandName}>
|
||||
{commandName}
|
||||
{sortedMovies.length ? (
|
||||
<span> - {sortedMovies.map((m) => m.title).join(', ')}</span>
|
||||
<span> - {formatTitles(sortedMovies.map((m) => m.title))}</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import _ from 'lodash';
|
||||
import { update } from 'Store/Actions/baseActions';
|
||||
|
||||
function updateEpisodes(section, episodes, episodeIds, options) {
|
||||
const data = _.reduce(episodes, (result, item) => {
|
||||
if (episodeIds.indexOf(item.id) > -1) {
|
||||
function updateMovies(section, movies, movieIds, options) {
|
||||
const data = _.reduce(movies, (result, item) => {
|
||||
if (movieIds.indexOf(item.id) > -1) {
|
||||
result.push({
|
||||
...item,
|
||||
...options
|
||||
@@ -18,4 +18,4 @@ function updateEpisodes(section, episodes, episodeIds, options) {
|
||||
return update({ section, data });
|
||||
}
|
||||
|
||||
export default updateEpisodes;
|
||||
export default updateMovies;
|
||||
301
frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
Normal file
301
frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
Normal file
@@ -0,0 +1,301 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import getFilterValue from 'Utilities/Filter/getFilterValue';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import CutoffUnmetRow from './CutoffUnmetRow';
|
||||
|
||||
function getMonitoredValue(props) {
|
||||
const {
|
||||
filters,
|
||||
selectedFilterKey
|
||||
} = props;
|
||||
|
||||
return getFilterValue(filters, selectedFilterKey, 'monitored', false);
|
||||
}
|
||||
|
||||
class CutoffUnmet extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
allSelected: false,
|
||||
allUnselected: false,
|
||||
lastToggled: null,
|
||||
selectedState: {},
|
||||
isConfirmSearchAllCutoffUnmetModalOpen: false,
|
||||
isInteractiveImportModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (hasDifferentItems(prevProps.items, this.props.items)) {
|
||||
this.setState((state) => {
|
||||
return removeOldSelectedState(state, prevProps.items);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
getSelectedIds = () => {
|
||||
return getSelectedIds(this.state.selectedState);
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onFilterMenuItemPress = (filterKey, filterValue) => {
|
||||
this.props.onFilterSelect(filterKey, filterValue);
|
||||
};
|
||||
|
||||
onSelectAllChange = ({ value }) => {
|
||||
this.setState(selectAll(this.state.selectedState, value));
|
||||
};
|
||||
|
||||
onSelectedChange = ({ id, value, shiftKey = false }) => {
|
||||
this.setState((state) => {
|
||||
return toggleSelected(state, this.props.items, id, value, shiftKey);
|
||||
});
|
||||
};
|
||||
|
||||
onSearchSelectedPress = () => {
|
||||
const selected = this.getSelectedIds();
|
||||
|
||||
this.props.onSearchSelectedPress(selected);
|
||||
};
|
||||
|
||||
onToggleSelectedPress = () => {
|
||||
const movieIds = this.getSelectedIds();
|
||||
|
||||
this.props.batchToggleCutoffUnmetMovies({
|
||||
movieIds,
|
||||
monitored: !getMonitoredValue(this.props)
|
||||
});
|
||||
};
|
||||
|
||||
onSearchAllCutoffUnmetPress = () => {
|
||||
this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true });
|
||||
};
|
||||
|
||||
onSearchAllCutoffUnmetConfirmed = () => {
|
||||
const {
|
||||
selectedFilterKey,
|
||||
onSearchAllCutoffUnmetPress
|
||||
} = this.props;
|
||||
|
||||
// TODO: Custom filters will need to check whether there is a monitored
|
||||
// filter once implemented.
|
||||
|
||||
onSearchAllCutoffUnmetPress(selectedFilterKey === 'monitored');
|
||||
this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
|
||||
};
|
||||
|
||||
onConfirmSearchAllCutoffUnmetModalClose = () => {
|
||||
this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
columns,
|
||||
totalRecords,
|
||||
isSearchingForCutoffUnmetMovies,
|
||||
isSaving,
|
||||
onFilterSelect,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
allSelected,
|
||||
allUnselected,
|
||||
selectedState,
|
||||
isConfirmSearchAllCutoffUnmetModalOpen
|
||||
} = this.state;
|
||||
|
||||
const itemsSelected = !!this.getSelectedIds().length;
|
||||
const isShowingMonitored = getMonitoredValue(this.props);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('CutoffUnmet')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('SearchSelected')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!itemsSelected || isSearchingForCutoffUnmetMovies}
|
||||
onPress={this.onSearchSelectedPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
|
||||
iconName={icons.MONITORED}
|
||||
isDisabled={!itemsSelected}
|
||||
isSpinning={isSaving}
|
||||
onPress={this.onToggleSelectedPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchAll')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!items.length}
|
||||
isSpinning={isSearchingForCutoffUnmetMovies}
|
||||
onPress={this.onSearchAllCutoffUnmetPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
{...otherProps}
|
||||
columns={columns}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={[]}
|
||||
onFilterSelect={onFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody>
|
||||
{
|
||||
isFetching && !isPopulated &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && error &&
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('CutoffUnmetLoadError')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !error && !items.length &&
|
||||
<Alert kind={kinds.INFO}>
|
||||
{translate('CutoffUnmetNoItems')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !error && !!items.length &&
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
selectAll={true}
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
{...otherProps}
|
||||
onSelectAllChange={this.onSelectAllChange}
|
||||
>
|
||||
<TableBody>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<CutoffUnmetRow
|
||||
key={item.id}
|
||||
isSelected={selectedState[item.id]}
|
||||
columns={columns}
|
||||
{...item}
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePager
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
{...otherProps}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmSearchAllCutoffUnmetModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('SearchForCutoffUnmetMovies')}
|
||||
message={
|
||||
<div>
|
||||
<div>
|
||||
{translate('SearchForCutoffUnmetMoviesConfirmationCount', { totalRecords })}
|
||||
</div>
|
||||
<div>
|
||||
{translate('MassSearchCancelWarning')}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
confirmLabel={translate('Search')}
|
||||
onConfirm={this.onSearchAllCutoffUnmetConfirmed}
|
||||
onCancel={this.onConfirmSearchAllCutoffUnmetModalClose}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CutoffUnmet.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
selectedFilterKey: PropTypes.string.isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
totalRecords: PropTypes.number,
|
||||
isSearchingForCutoffUnmetMovies: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
onFilterSelect: PropTypes.func.isRequired,
|
||||
onSearchSelectedPress: PropTypes.func.isRequired,
|
||||
batchToggleCutoffUnmetMovies: PropTypes.func.isRequired,
|
||||
onSearchAllCutoffUnmetPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CutoffUnmet;
|
||||
185
frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
Normal file
185
frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import withCurrentPage from 'Components/withCurrentPage';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
|
||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import * as wantedActions from 'Store/Actions/wantedActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||
import CutoffUnmet from './CutoffUnmet';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.wanted.cutoffUnmet,
|
||||
createCommandExecutingSelector(commandNames.CUTOFF_UNMET_MOVIES_SEARCH),
|
||||
(cutoffUnmet, isSearchingForCutoffUnmetMovies) => {
|
||||
return {
|
||||
isSearchingForCutoffUnmetMovies,
|
||||
isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1,
|
||||
...cutoffUnmet
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
...wantedActions,
|
||||
executeCommand,
|
||||
fetchQueueDetails,
|
||||
clearQueueDetails,
|
||||
fetchMovieFiles,
|
||||
clearMovieFiles
|
||||
};
|
||||
|
||||
class CutoffUnmetConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
useCurrentPage,
|
||||
fetchCutoffUnmet,
|
||||
gotoCutoffUnmetFirstPage
|
||||
} = this.props;
|
||||
|
||||
registerPagePopulator(this.repopulate, ['movieFileUpdated', 'movieFileDeleted']);
|
||||
|
||||
if (useCurrentPage) {
|
||||
fetchCutoffUnmet();
|
||||
} else {
|
||||
gotoCutoffUnmetFirstPage();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (hasDifferentItems(prevProps.items, this.props.items)) {
|
||||
const movieIds = selectUniqueIds(this.props.items, 'id');
|
||||
const movieFileIds = selectUniqueIds(this.props.items, 'movieFileId');
|
||||
|
||||
this.props.fetchQueueDetails({ movieIds });
|
||||
|
||||
if (movieFileIds.length) {
|
||||
this.props.fetchMovieFiles({ movieFileIds });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
unregisterPagePopulator(this.repopulate);
|
||||
this.props.clearCutoffUnmet();
|
||||
this.props.clearQueueDetails();
|
||||
this.props.clearMovieFiles();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
repopulate = () => {
|
||||
this.props.fetchCutoffUnmet();
|
||||
};
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onFirstPagePress = () => {
|
||||
this.props.gotoCutoffUnmetFirstPage();
|
||||
};
|
||||
|
||||
onPreviousPagePress = () => {
|
||||
this.props.gotoCutoffUnmetPreviousPage();
|
||||
};
|
||||
|
||||
onNextPagePress = () => {
|
||||
this.props.gotoCutoffUnmetNextPage();
|
||||
};
|
||||
|
||||
onLastPagePress = () => {
|
||||
this.props.gotoCutoffUnmetLastPage();
|
||||
};
|
||||
|
||||
onPageSelect = (page) => {
|
||||
this.props.gotoCutoffUnmetPage({ page });
|
||||
};
|
||||
|
||||
onSortPress = (sortKey) => {
|
||||
this.props.setCutoffUnmetSort({ sortKey });
|
||||
};
|
||||
|
||||
onFilterSelect = (selectedFilterKey) => {
|
||||
this.props.setCutoffUnmetFilter({ selectedFilterKey });
|
||||
};
|
||||
|
||||
onTableOptionChange = (payload) => {
|
||||
this.props.setCutoffUnmetTableOption(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
this.props.gotoCutoffUnmetFirstPage();
|
||||
}
|
||||
};
|
||||
|
||||
onSearchSelectedPress = (selected) => {
|
||||
this.props.executeCommand({
|
||||
name: commandNames.MOVIE_SEARCH,
|
||||
movieIds: selected
|
||||
});
|
||||
};
|
||||
|
||||
onSearchAllCutoffUnmetPress = (monitored) => {
|
||||
this.props.executeCommand({
|
||||
name: commandNames.CUTOFF_UNMET_MOVIES_SEARCH,
|
||||
monitored
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CutoffUnmet
|
||||
onFirstPagePress={this.onFirstPagePress}
|
||||
onPreviousPagePress={this.onPreviousPagePress}
|
||||
onNextPagePress={this.onNextPagePress}
|
||||
onLastPagePress={this.onLastPagePress}
|
||||
onPageSelect={this.onPageSelect}
|
||||
onSortPress={this.onSortPress}
|
||||
onFilterSelect={this.onFilterSelect}
|
||||
onTableOptionChange={this.onTableOptionChange}
|
||||
onSearchSelectedPress={this.onSearchSelectedPress}
|
||||
onToggleSelectedPress={this.onToggleSelectedPress}
|
||||
onSearchAllCutoffUnmetPress={this.onSearchAllCutoffUnmetPress}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CutoffUnmetConnector.propTypes = {
|
||||
useCurrentPage: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
fetchCutoffUnmet: PropTypes.func.isRequired,
|
||||
gotoCutoffUnmetFirstPage: PropTypes.func.isRequired,
|
||||
gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired,
|
||||
gotoCutoffUnmetNextPage: PropTypes.func.isRequired,
|
||||
gotoCutoffUnmetLastPage: PropTypes.func.isRequired,
|
||||
gotoCutoffUnmetPage: PropTypes.func.isRequired,
|
||||
setCutoffUnmetSort: PropTypes.func.isRequired,
|
||||
setCutoffUnmetFilter: PropTypes.func.isRequired,
|
||||
setCutoffUnmetTableOption: PropTypes.func.isRequired,
|
||||
clearCutoffUnmet: PropTypes.func.isRequired,
|
||||
executeCommand: PropTypes.func.isRequired,
|
||||
fetchQueueDetails: PropTypes.func.isRequired,
|
||||
clearQueueDetails: PropTypes.func.isRequired,
|
||||
fetchMovieFiles: PropTypes.func.isRequired,
|
||||
clearMovieFiles: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default withCurrentPage(
|
||||
connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector)
|
||||
);
|
||||
6
frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css
Normal file
6
frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.languages,
|
||||
.status {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 100px;
|
||||
}
|
||||
8
frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css.d.ts
vendored
Normal file
8
frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'languages': string;
|
||||
'status': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
157
frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
Normal file
157
frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import movieEntities from 'Movie/movieEntities';
|
||||
import MovieSearchCellConnector from 'Movie/MovieSearchCellConnector';
|
||||
import MovieStatusConnector from 'Movie/MovieStatusConnector';
|
||||
import MovieTitleLink from 'Movie/MovieTitleLink';
|
||||
import MovieFileLanguageConnector from 'MovieFile/MovieFileLanguageConnector';
|
||||
import styles from './CutoffUnmetRow.css';
|
||||
|
||||
function CutoffUnmetRow(props) {
|
||||
const {
|
||||
id,
|
||||
movieFileId,
|
||||
year,
|
||||
title,
|
||||
titleSlug,
|
||||
inCinemas,
|
||||
digitalRelease,
|
||||
physicalRelease,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableSelectCell
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>
|
||||
|
||||
{
|
||||
columns.map((column) => {
|
||||
const {
|
||||
name,
|
||||
isVisible
|
||||
} = column;
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.sortTitle') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieTitleLink
|
||||
titleSlug={titleSlug}
|
||||
title={title}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.year') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{year}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.inCinemas') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
key={name}
|
||||
className={styles[name]}
|
||||
date={inCinemas}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.digitalRelease') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
key={name}
|
||||
className={styles[name]}
|
||||
date={digitalRelease}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.physicalRelease') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
key={name}
|
||||
className={styles[name]}
|
||||
date={physicalRelease}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.languages}
|
||||
>
|
||||
<MovieFileLanguageConnector
|
||||
movieFileId={movieFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'status') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.status}
|
||||
>
|
||||
<MovieStatusConnector
|
||||
movieId={id}
|
||||
movieFileId={movieFileId}
|
||||
movieEntity={movieEntities.WANTED_CUTOFF_UNMET}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'actions') {
|
||||
return (
|
||||
<MovieSearchCellConnector
|
||||
key={name}
|
||||
movieId={id}
|
||||
movieTitle={title}
|
||||
movieEntity={movieEntities.WANTED_CUTOFF_UNMET}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
CutoffUnmetRow.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
movieFileId: PropTypes.number,
|
||||
title: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
inCinemas: PropTypes.string,
|
||||
digitalRelease: PropTypes.string,
|
||||
physicalRelease: PropTypes.string,
|
||||
isSelected: PropTypes.bool,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onSelectedChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CutoffUnmetRow;
|
||||
319
frontend/src/Wanted/Missing/Missing.js
Normal file
319
frontend/src/Wanted/Missing/Missing.js
Normal file
@@ -0,0 +1,319 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import getFilterValue from 'Utilities/Filter/getFilterValue';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import MissingRow from './MissingRow';
|
||||
|
||||
function getMonitoredValue(props) {
|
||||
const {
|
||||
filters,
|
||||
selectedFilterKey
|
||||
} = props;
|
||||
|
||||
return getFilterValue(filters, selectedFilterKey, 'monitored', false);
|
||||
}
|
||||
|
||||
class Missing extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
allSelected: false,
|
||||
allUnselected: false,
|
||||
lastToggled: null,
|
||||
selectedState: {},
|
||||
isConfirmSearchAllMissingModalOpen: false,
|
||||
isInteractiveImportModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (hasDifferentItems(prevProps.items, this.props.items)) {
|
||||
this.setState((state) => {
|
||||
return removeOldSelectedState(state, prevProps.items);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
getSelectedIds = () => {
|
||||
return getSelectedIds(this.state.selectedState);
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSelectAllChange = ({ value }) => {
|
||||
this.setState(selectAll(this.state.selectedState, value));
|
||||
};
|
||||
|
||||
onSelectedChange = ({ id, value, shiftKey = false }) => {
|
||||
this.setState((state) => {
|
||||
return toggleSelected(state, this.props.items, id, value, shiftKey);
|
||||
});
|
||||
};
|
||||
|
||||
onSearchSelectedPress = () => {
|
||||
const selected = this.getSelectedIds();
|
||||
|
||||
this.props.onSearchSelectedPress(selected);
|
||||
};
|
||||
|
||||
onToggleSelectedPress = () => {
|
||||
const movieIds = this.getSelectedIds();
|
||||
|
||||
this.props.batchToggleMissingMovies({
|
||||
movieIds,
|
||||
monitored: !getMonitoredValue(this.props)
|
||||
});
|
||||
};
|
||||
|
||||
onSearchAllMissingPress = () => {
|
||||
this.setState({ isConfirmSearchAllMissingModalOpen: true });
|
||||
};
|
||||
|
||||
onSearchAllMissingConfirmed = () => {
|
||||
const {
|
||||
selectedFilterKey,
|
||||
onSearchAllMissingPress
|
||||
} = this.props;
|
||||
|
||||
// TODO: Custom filters will need to check whether there is a monitored
|
||||
// filter once implemented.
|
||||
|
||||
onSearchAllMissingPress(selectedFilterKey === 'monitored');
|
||||
this.setState({ isConfirmSearchAllMissingModalOpen: false });
|
||||
};
|
||||
|
||||
onConfirmSearchAllMissingModalClose = () => {
|
||||
this.setState({ isConfirmSearchAllMissingModalOpen: false });
|
||||
};
|
||||
|
||||
onInteractiveImportPress = () => {
|
||||
this.setState({ isInteractiveImportModalOpen: true });
|
||||
};
|
||||
|
||||
onInteractiveImportModalClose = () => {
|
||||
this.setState({ isInteractiveImportModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
columns,
|
||||
totalRecords,
|
||||
isSearchingForMissingMovies,
|
||||
isSaving,
|
||||
onFilterSelect,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
allSelected,
|
||||
allUnselected,
|
||||
selectedState,
|
||||
isConfirmSearchAllMissingModalOpen,
|
||||
isInteractiveImportModalOpen
|
||||
} = this.state;
|
||||
|
||||
const itemsSelected = !!this.getSelectedIds().length;
|
||||
const isShowingMonitored = getMonitoredValue(this.props);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('Missing')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('SearchSelected')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!itemsSelected || isSearchingForMissingMovies}
|
||||
onPress={this.onSearchSelectedPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
|
||||
iconName={icons.MONITORED}
|
||||
isDisabled={!itemsSelected}
|
||||
isSpinning={isSaving}
|
||||
onPress={this.onToggleSelectedPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchAll')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!items.length}
|
||||
isSpinning={isSearchingForMissingMovies}
|
||||
onPress={this.onSearchAllMissingPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('ManualImport')}
|
||||
iconName={icons.INTERACTIVE}
|
||||
onPress={this.onInteractiveImportPress}
|
||||
/>
|
||||
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
{...otherProps}
|
||||
columns={columns}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={[]}
|
||||
onFilterSelect={onFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody>
|
||||
{
|
||||
isFetching && !isPopulated &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && error &&
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('MissingLoadError')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !error && !items.length &&
|
||||
<Alert kind={kinds.INFO}>
|
||||
{translate('MissingNoItems')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !error && !!items.length &&
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
selectAll={true}
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
{...otherProps}
|
||||
onSelectAllChange={this.onSelectAllChange}
|
||||
>
|
||||
<TableBody>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<MissingRow
|
||||
key={item.id}
|
||||
isSelected={selectedState[item.id]}
|
||||
columns={columns}
|
||||
{...item}
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePager
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
{...otherProps}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmSearchAllMissingModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('SearchForAllMissingMovies')}
|
||||
message={
|
||||
<div>
|
||||
<div>
|
||||
{translate('SearchForAllMissingMoviesConfirmationCount', { totalRecords })}
|
||||
</div>
|
||||
<div>
|
||||
{translate('MassSearchCancelWarning')}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
confirmLabel={translate('Search')}
|
||||
onConfirm={this.onSearchAllMissingConfirmed}
|
||||
onCancel={this.onConfirmSearchAllMissingModalClose}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isInteractiveImportModalOpen}
|
||||
onModalClose={this.onInteractiveImportModalClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Missing.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
selectedFilterKey: PropTypes.string.isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
totalRecords: PropTypes.number,
|
||||
isSearchingForMissingMovies: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
onFilterSelect: PropTypes.func.isRequired,
|
||||
onSearchSelectedPress: PropTypes.func.isRequired,
|
||||
batchToggleMissingMovies: PropTypes.func.isRequired,
|
||||
onSearchAllMissingPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Missing;
|
||||
173
frontend/src/Wanted/Missing/MissingConnector.js
Normal file
173
frontend/src/Wanted/Missing/MissingConnector.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import withCurrentPage from 'Components/withCurrentPage';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import * as wantedActions from 'Store/Actions/wantedActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||
import Missing from './Missing';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.wanted.missing,
|
||||
createCommandExecutingSelector(commandNames.MISSING_MOVIES_SEARCH),
|
||||
(missing, isSearchingForMissingMovies) => {
|
||||
return {
|
||||
isSearchingForMissingMovies,
|
||||
isSaving: missing.items.filter((m) => m.isSaving).length > 1,
|
||||
...missing
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
...wantedActions,
|
||||
executeCommand,
|
||||
fetchQueueDetails,
|
||||
clearQueueDetails
|
||||
};
|
||||
|
||||
class MissingConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
useCurrentPage,
|
||||
fetchMissing,
|
||||
gotoMissingFirstPage
|
||||
} = this.props;
|
||||
|
||||
registerPagePopulator(this.repopulate, ['movieFileUpdated', 'movieFileDeleted']);
|
||||
|
||||
if (useCurrentPage) {
|
||||
fetchMissing();
|
||||
} else {
|
||||
gotoMissingFirstPage();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (hasDifferentItems(prevProps.items, this.props.items)) {
|
||||
const movieIds = selectUniqueIds(this.props.items, 'id');
|
||||
this.props.fetchQueueDetails({ movieIds });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
unregisterPagePopulator(this.repopulate);
|
||||
this.props.clearMissing();
|
||||
this.props.clearQueueDetails();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
repopulate = () => {
|
||||
this.props.fetchMissing();
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onFirstPagePress = () => {
|
||||
this.props.gotoMissingFirstPage();
|
||||
};
|
||||
|
||||
onPreviousPagePress = () => {
|
||||
this.props.gotoMissingPreviousPage();
|
||||
};
|
||||
|
||||
onNextPagePress = () => {
|
||||
this.props.gotoMissingNextPage();
|
||||
};
|
||||
|
||||
onLastPagePress = () => {
|
||||
this.props.gotoMissingLastPage();
|
||||
};
|
||||
|
||||
onPageSelect = (page) => {
|
||||
this.props.gotoMissingPage({ page });
|
||||
};
|
||||
|
||||
onSortPress = (sortKey) => {
|
||||
this.props.setMissingSort({ sortKey });
|
||||
};
|
||||
|
||||
onFilterSelect = (selectedFilterKey) => {
|
||||
this.props.setMissingFilter({ selectedFilterKey });
|
||||
};
|
||||
|
||||
onTableOptionChange = (payload) => {
|
||||
this.props.setMissingTableOption(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
this.props.gotoMissingFirstPage();
|
||||
}
|
||||
};
|
||||
|
||||
onSearchSelectedPress = (selected) => {
|
||||
this.props.executeCommand({
|
||||
name: commandNames.MOVIE_SEARCH,
|
||||
movieIds: selected
|
||||
});
|
||||
};
|
||||
|
||||
onSearchAllMissingPress = (monitored) => {
|
||||
this.props.executeCommand({
|
||||
name: commandNames.MISSING_MOVIES_SEARCH,
|
||||
monitored
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Missing
|
||||
onFirstPagePress={this.onFirstPagePress}
|
||||
onPreviousPagePress={this.onPreviousPagePress}
|
||||
onNextPagePress={this.onNextPagePress}
|
||||
onLastPagePress={this.onLastPagePress}
|
||||
onPageSelect={this.onPageSelect}
|
||||
onSortPress={this.onSortPress}
|
||||
onFilterSelect={this.onFilterSelect}
|
||||
onTableOptionChange={this.onTableOptionChange}
|
||||
onSearchSelectedPress={this.onSearchSelectedPress}
|
||||
onSearchAllMissingPress={this.onSearchAllMissingPress}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MissingConnector.propTypes = {
|
||||
useCurrentPage: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
fetchMissing: PropTypes.func.isRequired,
|
||||
gotoMissingFirstPage: PropTypes.func.isRequired,
|
||||
gotoMissingPreviousPage: PropTypes.func.isRequired,
|
||||
gotoMissingNextPage: PropTypes.func.isRequired,
|
||||
gotoMissingLastPage: PropTypes.func.isRequired,
|
||||
gotoMissingPage: PropTypes.func.isRequired,
|
||||
setMissingSort: PropTypes.func.isRequired,
|
||||
setMissingFilter: PropTypes.func.isRequired,
|
||||
setMissingTableOption: PropTypes.func.isRequired,
|
||||
clearMissing: PropTypes.func.isRequired,
|
||||
executeCommand: PropTypes.func.isRequired,
|
||||
fetchQueueDetails: PropTypes.func.isRequired,
|
||||
clearQueueDetails: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default withCurrentPage(
|
||||
connect(createMapStateToProps, mapDispatchToProps)(MissingConnector)
|
||||
);
|
||||
5
frontend/src/Wanted/Missing/MissingRow.css
Normal file
5
frontend/src/Wanted/Missing/MissingRow.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.status {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 100px;
|
||||
}
|
||||
7
frontend/src/Wanted/Missing/MissingRow.css.d.ts
vendored
Normal file
7
frontend/src/Wanted/Missing/MissingRow.css.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'status': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
147
frontend/src/Wanted/Missing/MissingRow.js
Normal file
147
frontend/src/Wanted/Missing/MissingRow.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import movieEntities from 'Movie/movieEntities';
|
||||
import MovieSearchCellConnector from 'Movie/MovieSearchCellConnector';
|
||||
import MovieStatusConnector from 'Movie/MovieStatusConnector';
|
||||
import MovieTitleLink from 'Movie/MovieTitleLink';
|
||||
import styles from './MissingRow.css';
|
||||
|
||||
function MissingRow(props) {
|
||||
const {
|
||||
id,
|
||||
movieFileId,
|
||||
year,
|
||||
title,
|
||||
titleSlug,
|
||||
inCinemas,
|
||||
digitalRelease,
|
||||
physicalRelease,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange
|
||||
} = props;
|
||||
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableSelectCell
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>
|
||||
|
||||
{
|
||||
columns.map((column) => {
|
||||
const {
|
||||
name,
|
||||
isVisible
|
||||
} = column;
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.sortTitle') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieTitleLink
|
||||
titleSlug={titleSlug}
|
||||
title={title}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.year') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{year}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.inCinemas') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
key={name}
|
||||
className={styles[name]}
|
||||
date={inCinemas}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.digitalRelease') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
key={name}
|
||||
className={styles[name]}
|
||||
date={digitalRelease}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.physicalRelease') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
key={name}
|
||||
className={styles[name]}
|
||||
date={physicalRelease}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'status') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.status}
|
||||
>
|
||||
<MovieStatusConnector
|
||||
movieId={id}
|
||||
movieFileId={movieFileId}
|
||||
movieEntity={movieEntities.WANTED_MISSING}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'actions') {
|
||||
return (
|
||||
<MovieSearchCellConnector
|
||||
key={name}
|
||||
movieId={id}
|
||||
movieTitle={title}
|
||||
movieEntity={movieEntities.WANTED_MISSING}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
MissingRow.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
movieFileId: PropTypes.number,
|
||||
title: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
inCinemas: PropTypes.string,
|
||||
digitalRelease: PropTypes.string,
|
||||
physicalRelease: PropTypes.string,
|
||||
isSelected: PropTypes.bool,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onSelectedChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default MissingRow;
|
||||
@@ -57,8 +57,8 @@
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #f5f7fa;
|
||||
color: #656565;
|
||||
background-color: var(--pageBackground);
|
||||
color: var(--textColor);
|
||||
font-family: "Roboto", "open sans", "Helvetica Neue", Helvetica, Arial,
|
||||
sans-serif;
|
||||
}
|
||||
@@ -88,14 +88,14 @@
|
||||
padding: 10px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
background-color: #464b51;
|
||||
background-color: var(--themeDarkColor);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 20px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: #fff;
|
||||
background-color: var(--panelBackground);
|
||||
}
|
||||
|
||||
.sign-in {
|
||||
@@ -112,16 +112,18 @@
|
||||
padding: 6px 16px;
|
||||
width: 100%;
|
||||
height: 35px;
|
||||
border: 1px solid #dde6e9;
|
||||
background-color: var(--inputBackgroundColor);
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
|
||||
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor);
|
||||
color: var(--textColor);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: 0;
|
||||
border-color: #66afe9;
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075),
|
||||
0 0 8px rgba(102, 175, 233, 0.6);
|
||||
border-color: var(--inputFocusBorderColor);
|
||||
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor),
|
||||
0 0 8px var(--inputFocusBoxShadowColor);
|
||||
}
|
||||
|
||||
.button {
|
||||
@@ -130,10 +132,10 @@
|
||||
padding: 10px 0;
|
||||
width: 100%;
|
||||
border: 1px solid;
|
||||
border-color: #5899eb;
|
||||
border-color: var(--primaryBorderColor);
|
||||
border-radius: 4px;
|
||||
background-color: #5d9cec;
|
||||
color: #fff;
|
||||
background-color: var(--primaryBackgroundColor);
|
||||
color: var(--white);
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
@@ -141,9 +143,9 @@
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
border-color: #3483e7;
|
||||
background-color: #4b91ea;
|
||||
color: #fff;
|
||||
border-color: var(--primaryHoverBorderColor);
|
||||
background-color: var(--primaryHoverBackgroundColor);
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -165,24 +167,24 @@
|
||||
|
||||
.forgot-password {
|
||||
margin-left: auto;
|
||||
color: #909fa7;
|
||||
color: var(--forgotPasswordColor);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.forgot-password:focus,
|
||||
.forgot-password:hover {
|
||||
color: #748690;
|
||||
color: var(--forgotPasswordAltColor);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.forgot-password:visited {
|
||||
color: #748690;
|
||||
color: var(--forgotPasswordAltColor);
|
||||
}
|
||||
|
||||
.login-failed {
|
||||
margin-top: 20px;
|
||||
color: #f05050;
|
||||
color: var(--failedColor);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -291,5 +293,59 @@
|
||||
|
||||
loginFailedDiv.classList.remove("hidden");
|
||||
}
|
||||
|
||||
var light = {
|
||||
white: '#fff',
|
||||
pageBackground: '#f5f7fa',
|
||||
textColor: '#515253',
|
||||
themeDarkColor: '#464b51',
|
||||
panelBackground: '#fff',
|
||||
inputBackgroundColor: '#fff',
|
||||
inputBorderColor: '#dde6e9',
|
||||
inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
|
||||
inputFocusBorderColor: '#66afe9',
|
||||
inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
|
||||
primaryBackgroundColor: '#5d9cec',
|
||||
primaryBorderColor: '#5899eb',
|
||||
primaryHoverBackgroundColor: '#4b91ea',
|
||||
primaryHoverBorderColor: '#3483e7',
|
||||
failedColor: '#f05050',
|
||||
forgotPasswordColor: '#909fa7',
|
||||
forgotPasswordAltColor: '#748690'
|
||||
};
|
||||
|
||||
var dark = {
|
||||
white: '#fff',
|
||||
pageBackground: '#202020',
|
||||
textColor: '#ccc',
|
||||
themeDarkColor: '#494949',
|
||||
panelBackground: '#111',
|
||||
inputBackgroundColor: '#333',
|
||||
inputBorderColor: '#dde6e9',
|
||||
inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
|
||||
inputFocusBorderColor: '#66afe9',
|
||||
inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
|
||||
primaryBackgroundColor: '#5d9cec',
|
||||
primaryBorderColor: '#5899eb',
|
||||
primaryHoverBackgroundColor: '#4b91ea',
|
||||
primaryHoverBorderColor: '#3483e7',
|
||||
failedColor: '#f05050',
|
||||
forgotPasswordColor: '#737d83',
|
||||
forgotPasswordAltColor: '#546067'
|
||||
};
|
||||
|
||||
var theme = "_THEME_";
|
||||
var defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var finalTheme = theme === 'dark' || (theme === 'auto' && defaultDark) ?
|
||||
dark :
|
||||
light;
|
||||
|
||||
Object.entries(finalTheme).forEach(([key, value]) => {
|
||||
document.documentElement.style.setProperty(
|
||||
`--${key}`,
|
||||
value
|
||||
);
|
||||
});
|
||||
|
||||
</script>
|
||||
</html>
|
||||
|
||||
16
frontend/src/typings/Blocklist.ts
Normal file
16
frontend/src/typings/Blocklist.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
|
||||
interface Blocklist extends ModelBase {
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
customFormats: CustomFormat[];
|
||||
title: string;
|
||||
date?: string;
|
||||
protocol: string;
|
||||
movieId?: number;
|
||||
}
|
||||
|
||||
export default Blocklist;
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface UiSettings {
|
||||
theme: string;
|
||||
theme: 'auto' | 'dark' | 'light';
|
||||
showRelativeDates: boolean;
|
||||
shortDateFormat: string;
|
||||
longDateFormat: string;
|
||||
|
||||
42
package.json
42
package.json
@@ -29,11 +29,11 @@
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@juggle/resize-observer": "3.4.0",
|
||||
"@microsoft/signalr": "6.0.25",
|
||||
"@sentry/browser": "7.51.2",
|
||||
"@sentry/integrations": "7.51.2",
|
||||
"@types/node": "18.16.8",
|
||||
"@types/react": "18.2.6",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"@sentry/browser": "7.100.0",
|
||||
"@sentry/integrations": "7.100.0",
|
||||
"@types/node": "18.19.31",
|
||||
"@types/react": "18.2.79",
|
||||
"@types/react-dom": "18.2.25",
|
||||
"classnames": "2.3.2",
|
||||
"clipboard": "2.0.11",
|
||||
"connected-react-router": "6.9.3",
|
||||
@@ -84,16 +84,16 @@
|
||||
"reselect": "4.1.8",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"swiper": "8.3.2",
|
||||
"typescript": "4.9.5"
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.22.11",
|
||||
"@babel/eslint-parser": "7.22.11",
|
||||
"@babel/plugin-proposal-export-default-from": "7.22.5",
|
||||
"@babel/core": "7.24.4",
|
||||
"@babel/eslint-parser": "7.24.1",
|
||||
"@babel/plugin-proposal-export-default-from": "7.24.1",
|
||||
"@babel/plugin-syntax-dynamic-import": "7.8.3",
|
||||
"@babel/preset-env": "7.22.14",
|
||||
"@babel/preset-react": "7.22.5",
|
||||
"@babel/preset-typescript": "7.22.11",
|
||||
"@babel/preset-env": "7.24.4",
|
||||
"@babel/preset-react": "7.24.1",
|
||||
"@babel/preset-typescript": "7.24.1",
|
||||
"@types/lodash": "4.14.195",
|
||||
"@types/react-lazyload": "3.2.0",
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
@@ -101,31 +101,31 @@
|
||||
"@types/react-window": "1.8.5",
|
||||
"@types/redux-actions": "2.6.2",
|
||||
"@types/webpack-livereload-plugin": "2.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.5",
|
||||
"@typescript-eslint/parser": "5.59.5",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-inline-classnames": "2.0.1",
|
||||
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
|
||||
"core-js": "3.32.1",
|
||||
"core-js": "3.37.0",
|
||||
"css-loader": "6.7.3",
|
||||
"css-modules-typescript-loader": "4.0.1",
|
||||
"eslint": "8.45.0",
|
||||
"eslint-config-prettier": "8.8.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "8.10.0",
|
||||
"eslint-plugin-filenames": "1.3.2",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-json": "3.1.0",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react": "7.34.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "10.0.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.0",
|
||||
"file-loader": "6.2.0",
|
||||
"filemanager-webpack-plugin": "8.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "8.0.0",
|
||||
"html-webpack-plugin": "5.5.3",
|
||||
"loader-utils": "^3.2.1",
|
||||
"mini-css-extract-plugin": "2.7.6",
|
||||
"postcss": "8.4.23",
|
||||
"postcss": "8.4.38",
|
||||
"postcss-color-function": "4.1.0",
|
||||
"postcss-loader": "7.3.0",
|
||||
"postcss-mixins": "9.0.4",
|
||||
|
||||
@@ -99,10 +99,39 @@
|
||||
<RootNamespace Condition="'$(RadarrProject)'=='true'">$(MSBuildProjectName.Replace('Radarr','NzbDrone'))</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TestProject)'!='true'">
|
||||
<!-- Annotates .NET assemblies with repository information including SHA -->
|
||||
<!-- Sentry uses this to link directly to GitHub at the exact version/file/line -->
|
||||
<!-- This is built-in on .NET 8 and can be removed once the project is updated -->
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Sentry specific configuration: Only in Release mode -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||
<!-- https://docs.sentry.io/platforms/dotnet/configuration/msbuild/ -->
|
||||
<!-- OrgSlug, ProjectSlug and AuthToken are required.
|
||||
They can be set below, via argument to 'msbuild -p:' or environment variable -->
|
||||
<SentryOrg></SentryOrg>
|
||||
<SentryProject></SentryProject>
|
||||
<SentryUrl></SentryUrl> <!-- If empty, assumed to be sentry.io -->
|
||||
<SentryAuthToken></SentryAuthToken> <!-- Use env var instead: SENTRY_AUTH_TOKEN -->
|
||||
|
||||
<!-- Upload PDBs to Sentry, enabling stack traces with line numbers and file paths
|
||||
without the need to deploy the application with PDBs -->
|
||||
<SentryUploadSymbols>true</SentryUploadSymbols>
|
||||
|
||||
<!-- Source Link settings -->
|
||||
<!-- https://github.com/dotnet/sourcelink/blob/main/docs/README.md#publishrepositoryurl -->
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<!-- Embeds all source code in the respective PDB. This can make it a bit bigger but since it'll be uploaded
|
||||
to Sentry and not distributed to run on the server, it helps debug crashes while making releases smaller -->
|
||||
<EmbedAllSources>true</EmbedAllSources>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Standard testing packages -->
|
||||
<ItemGroup Condition="'$(TestProject)'=='true'">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="NUnit" Version="3.14.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
|
||||
<PackageReference Include="NunitXml.TestLogger" Version="3.0.131" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -55,6 +55,16 @@ namespace NzbDrone.Automation.Test
|
||||
_page.Find(By.LinkText("Blocklist")).Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void wanted_page()
|
||||
{
|
||||
_page.WantedNavIcon.Click();
|
||||
_page.WaitForNoSpinner();
|
||||
|
||||
_page.Find(By.LinkText("Missing")).Should().NotBeNull();
|
||||
_page.Find(By.LinkText("Cutoff Unmet")).Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void system_page()
|
||||
{
|
||||
|
||||
@@ -57,6 +57,8 @@ namespace NzbDrone.Automation.Test.PageModel
|
||||
|
||||
public IWebElement ActivityNavIcon => Find(By.LinkText("Activity"));
|
||||
|
||||
public IWebElement WantedNavIcon => Find(By.LinkText("Wanted"));
|
||||
|
||||
public IWebElement SettingNavIcon => Find(By.LinkText("Settings"));
|
||||
|
||||
public IWebElement SystemNavIcon => Find(By.PartialLinkText("System"));
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Options;
|
||||
using NzbDrone.Core.Authentication;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Test.Common;
|
||||
@@ -43,6 +45,26 @@ namespace NzbDrone.Common.Test
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(v => v.WriteAllText(configFile, It.IsAny<string>()))
|
||||
.Callback<string, string>((p, t) => _configFileContents = t);
|
||||
|
||||
Mocker.GetMock<IOptions<AuthOptions>>()
|
||||
.Setup(v => v.Value)
|
||||
.Returns(new AuthOptions());
|
||||
|
||||
Mocker.GetMock<IOptions<AppOptions>>()
|
||||
.Setup(v => v.Value)
|
||||
.Returns(new AppOptions());
|
||||
|
||||
Mocker.GetMock<IOptions<ServerOptions>>()
|
||||
.Setup(v => v.Value)
|
||||
.Returns(new ServerOptions());
|
||||
|
||||
Mocker.GetMock<IOptions<LogOptions>>()
|
||||
.Setup(v => v.Value)
|
||||
.Returns(new LogOptions());
|
||||
|
||||
Mocker.GetMock<IOptions<UpdateOptions>>()
|
||||
.Setup(v => v.Value)
|
||||
.Returns(new UpdateOptions());
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using FluentAssertions;
|
||||
using NLog;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Instrumentation.Sentry;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
@@ -43,7 +44,7 @@ namespace NzbDrone.Common.Test.InstrumentationTests
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111");
|
||||
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111", Mocker.GetMock<IAppFolderInfo>().Object);
|
||||
}
|
||||
|
||||
private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message)
|
||||
|
||||
@@ -10,6 +10,7 @@ using NUnit.Framework;
|
||||
using NzbDrone.Common.Composition.Extensions;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Instrumentation.Extensions;
|
||||
using NzbDrone.Common.Options;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Datastore.Extensions;
|
||||
using NzbDrone.Core.Lifecycle;
|
||||
@@ -29,10 +30,16 @@ namespace NzbDrone.Common.Test
|
||||
.AddNzbDroneLogger()
|
||||
.AutoAddServices(Bootstrap.ASSEMBLIES)
|
||||
.AddDummyDatabase()
|
||||
.AddDummyLogDatabase()
|
||||
.AddStartupContext(new StartupContext("first", "second"));
|
||||
|
||||
container.RegisterInstance(new Mock<IHostLifetime>().Object);
|
||||
container.RegisterInstance(new Mock<IOptions<PostgresOptions>>().Object);
|
||||
container.RegisterInstance(new Mock<IOptions<AppOptions>>().Object);
|
||||
container.RegisterInstance(new Mock<IOptions<AuthOptions>>().Object);
|
||||
container.RegisterInstance(new Mock<IOptions<ServerOptions>>().Object);
|
||||
container.RegisterInstance(new Mock<IOptions<LogOptions>>().Object);
|
||||
container.RegisterInstance(new Mock<IOptions<UpdateOptions>>().Object);
|
||||
|
||||
var serviceProvider = container.GetServiceProvider();
|
||||
|
||||
|
||||
@@ -153,7 +153,11 @@ namespace NzbDrone.Common.Disk
|
||||
{
|
||||
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
|
||||
|
||||
return Directory.EnumerateDirectories(path);
|
||||
return Directory.EnumerateDirectories(path, "*", new EnumerationOptions
|
||||
{
|
||||
AttributesToSkip = FileAttributes.System,
|
||||
IgnoreInaccessible = true
|
||||
});
|
||||
}
|
||||
|
||||
public IEnumerable<string> GetFiles(string path, bool recursive)
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace NzbDrone.Common.Instrumentation
|
||||
RegisterDebugger();
|
||||
}
|
||||
|
||||
RegisterSentry(updateApp);
|
||||
RegisterSentry(updateApp, appFolderInfo);
|
||||
|
||||
if (updateApp)
|
||||
{
|
||||
@@ -62,7 +62,7 @@ namespace NzbDrone.Common.Instrumentation
|
||||
LogManager.ReconfigExistingLoggers();
|
||||
}
|
||||
|
||||
private static void RegisterSentry(bool updateClient)
|
||||
private static void RegisterSentry(bool updateClient, IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
string dsn;
|
||||
|
||||
@@ -77,7 +77,7 @@ namespace NzbDrone.Common.Instrumentation
|
||||
: "https://998b4673d4c849ccb5277b5966ed5bc2@sentry.servarr.com/10";
|
||||
}
|
||||
|
||||
var target = new SentryTarget(dsn)
|
||||
var target = new SentryTarget(dsn, appFolderInfo)
|
||||
{
|
||||
Name = "sentryTarget",
|
||||
Layout = "${message}"
|
||||
|
||||
@@ -10,6 +10,7 @@ using NLog.Common;
|
||||
using NLog.Targets;
|
||||
using Npgsql;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using Sentry;
|
||||
|
||||
namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
@@ -105,7 +106,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
public bool FilterEvents { get; set; }
|
||||
public bool SentryEnabled { get; set; }
|
||||
|
||||
public SentryTarget(string dsn)
|
||||
public SentryTarget(string dsn, IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
_sdk = SentrySdk.Init(o =>
|
||||
{
|
||||
@@ -113,9 +114,33 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
o.AttachStacktrace = true;
|
||||
o.MaxBreadcrumbs = 200;
|
||||
o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}";
|
||||
o.BeforeSend = x => SentryCleanser.CleanseEvent(x);
|
||||
o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x);
|
||||
o.SetBeforeSend(x => SentryCleanser.CleanseEvent(x));
|
||||
o.SetBeforeBreadcrumb(x => SentryCleanser.CleanseBreadcrumb(x));
|
||||
o.Environment = BuildInfo.Branch;
|
||||
|
||||
// Crash free run statistics (sends a ping for healthy and for crashes sessions)
|
||||
o.AutoSessionTracking = true;
|
||||
|
||||
// Caches files in the event device is offline
|
||||
// Sentry creates a 'sentry' sub directory, no need to concat here
|
||||
o.CacheDirectoryPath = appFolderInfo.GetAppDataPath();
|
||||
|
||||
// default environment is production
|
||||
if (!RuntimeInfo.IsProduction)
|
||||
{
|
||||
if (RuntimeInfo.IsDevelopment)
|
||||
{
|
||||
o.Environment = "development";
|
||||
}
|
||||
else if (RuntimeInfo.IsTesting)
|
||||
{
|
||||
o.Environment = "testing";
|
||||
}
|
||||
else
|
||||
{
|
||||
o.Environment = "other";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
InitializeScope();
|
||||
@@ -133,7 +158,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
{
|
||||
SentrySdk.ConfigureScope(scope =>
|
||||
{
|
||||
scope.User = new User
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = HashUtil.AnonymousToken()
|
||||
};
|
||||
@@ -336,13 +361,21 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
}
|
||||
}
|
||||
|
||||
var level = LoggingLevelMap[logEvent.Level];
|
||||
var sentryEvent = new SentryEvent(logEvent.Exception)
|
||||
{
|
||||
Level = LoggingLevelMap[logEvent.Level],
|
||||
Level = level,
|
||||
Logger = logEvent.LoggerName,
|
||||
Message = logEvent.FormattedMessage
|
||||
};
|
||||
|
||||
if (level is SentryLevel.Fatal && logEvent.Exception is not null)
|
||||
{
|
||||
// Usages of 'fatal' here indicates the process will crash. In Sentry this is represented with
|
||||
// the 'unhandled' exception flag
|
||||
logEvent.Exception.SetSentryMechanism("Logger.Fatal", "Logger.Fatal was called", false);
|
||||
}
|
||||
|
||||
sentryEvent.SetExtras(extras);
|
||||
sentryEvent.SetFingerprint(fingerPrint);
|
||||
|
||||
|
||||
8
src/NzbDrone.Common/Options/AppOptions.cs
Normal file
8
src/NzbDrone.Common/Options/AppOptions.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace NzbDrone.Common.Options;
|
||||
|
||||
public class AppOptions
|
||||
{
|
||||
public string InstanceName { get; set; }
|
||||
public string Theme { get; set; }
|
||||
public bool? LaunchBrowser { get; set; }
|
||||
}
|
||||
9
src/NzbDrone.Common/Options/AuthOptions.cs
Normal file
9
src/NzbDrone.Common/Options/AuthOptions.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace NzbDrone.Common.Options;
|
||||
|
||||
public class AuthOptions
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public string Method { get; set; }
|
||||
public string Required { get; set; }
|
||||
}
|
||||
15
src/NzbDrone.Common/Options/LogOptions.cs
Normal file
15
src/NzbDrone.Common/Options/LogOptions.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace NzbDrone.Common.Options;
|
||||
|
||||
public class LogOptions
|
||||
{
|
||||
public string Level { get; set; }
|
||||
public bool? FilterSentryEvents { get; set; }
|
||||
public int? Rotate { get; set; }
|
||||
public bool? Sql { get; set; }
|
||||
public string ConsoleLevel { get; set; }
|
||||
public bool? AnalyticsEnabled { get; set; }
|
||||
public string SyslogServer { get; set; }
|
||||
public int? SyslogPort { get; set; }
|
||||
public string SyslogLevel { get; set; }
|
||||
public bool? DbEnabled { get; set; }
|
||||
}
|
||||
12
src/NzbDrone.Common/Options/ServerOptions.cs
Normal file
12
src/NzbDrone.Common/Options/ServerOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace NzbDrone.Common.Options;
|
||||
|
||||
public class ServerOptions
|
||||
{
|
||||
public string UrlBase { get; set; }
|
||||
public string BindAddress { get; set; }
|
||||
public int? Port { get; set; }
|
||||
public bool? EnableSsl { get; set; }
|
||||
public int? SslPort { get; set; }
|
||||
public string SslCertPath { get; set; }
|
||||
public string SslCertPassword { get; set; }
|
||||
}
|
||||
9
src/NzbDrone.Common/Options/UpdateOptions.cs
Normal file
9
src/NzbDrone.Common/Options/UpdateOptions.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace NzbDrone.Common.Options;
|
||||
|
||||
public class UpdateOptions
|
||||
{
|
||||
public string Mechanism { get; set; }
|
||||
public bool? Automatically { get; set; }
|
||||
public string ScriptPath { get; set; }
|
||||
public string Branch { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user