1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-17 21:26:13 -04:00

Compare commits

...

85 Commits

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

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

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

Co-authored-by: Ano10 <arnaudthommeray+github@ik.me>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-08-11 08:44:27 -07:00
Weblate
b1527f9abb Multiple Translations updated by Weblate
ignore-downstream

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

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

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

Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Wolfy The Broccoly <theproviderofsolace@gmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-07-30 21:24:50 -07:00
334 changed files with 5908 additions and 4065 deletions

View File

@@ -22,7 +22,7 @@ env:
FRAMEWORK: net6.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 4
VERSION: 4.0.8
VERSION: 4.0.9
jobs:
backend:

View File

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

View File

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

View File

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

View File

@@ -134,6 +134,12 @@ module.exports = (env) => {
{
source: 'frontend/src/Content/robots.txt',
destination: path.join(distFolder, 'Content/robots.txt')
},
// manifest.json and browserconfig.xml
{
source: 'frontend/src/Content/*.(json|xml)',
destination: path.join(distFolder, 'Content')
}
]
}

View File

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

View File

@@ -59,6 +59,7 @@ function Blocklist() {
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
isRemoving,
@@ -223,6 +224,7 @@ function Blocklist() {
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton
@@ -264,6 +266,7 @@ function Blocklist() {
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange}

View File

@@ -51,7 +51,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
sourceTitle,
data,
downloadId,
isMarkingAsFailed,
isMarkingAsFailed = false,
shortDateFormat,
timeFormat,
onMarkAsFailedPress,
@@ -93,8 +93,4 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
);
}
HistoryDetailsModal.defaultProps = {
isMarkingAsFailed: false,
};
export default HistoryDetailsModal;

View File

@@ -53,6 +53,7 @@ function History() {
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
} = useSelector((state: AppState) => state.history);
@@ -154,6 +155,7 @@ function History() {
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton
@@ -193,6 +195,7 @@ function History() {
<div>
<Table
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange}

View File

@@ -15,6 +15,7 @@ import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import useEpisode from 'Episode/useEpisode';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
@@ -31,7 +32,7 @@ interface HistoryRowProps {
id: number;
episodeId: number;
seriesId: number;
languages: object[];
languages: Language[];
quality: QualityModel;
customFormats?: CustomFormat[];
customFormatScore: number;
@@ -61,7 +62,7 @@ function HistoryRow(props: HistoryRowProps) {
date,
data,
downloadId,
isMarkingAsFailed,
isMarkingAsFailed = false,
markAsFailedError,
columns,
} = props;
@@ -268,8 +269,4 @@ function HistoryRow(props: HistoryRowProps) {
);
}
HistoryRow.defaultProps = {
customFormats: [],
};
export default HistoryRow;

View File

@@ -73,6 +73,7 @@ function Queue() {
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
isGrabbing,
@@ -269,8 +270,10 @@ function Queue() {
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
@@ -344,6 +347,7 @@ function Queue() {
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
maxPageSize={200}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange}

View File

@@ -1,11 +1,11 @@
import React, { Fragment, useCallback } from 'react';
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import { setQueueOption } from 'Store/Actions/queueActions';
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
import { CheckInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
@@ -22,24 +22,26 @@ function QueueOptions() {
[name]: value,
})
);
if (name === 'includeUnknownSeriesItems') {
dispatch(gotoQueuePage({ page: 1 }));
}
},
[dispatch]
);
return (
<Fragment>
<FormGroup>
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
<FormGroup>
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')}
onChange={handleOptionChange}
/>
</FormGroup>
</Fragment>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')}
onChange={handleOptionChange}
/>
</FormGroup>
);
}

View File

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

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Icon from 'Components/Icon';
import Icon, { IconProps } from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds } from 'Helpers/Props';
import TooltipPosition from 'Helpers/Props/TooltipPosition';
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
// status === 'downloading'
let iconName = icons.DOWNLOADING;
let iconKind = kinds.DEFAULT;
let iconKind: IconProps['kind'] = kinds.DEFAULT;
let title = translate('Downloading');
if (status === 'paused') {
@@ -155,10 +155,4 @@ function QueueStatus(props: QueueStatusProps) {
);
}
QueueStatus.defaultProps = {
trackedDownloadStatus: 'ok',
trackedDownloadState: 'downloading',
canFlip: false,
};
export default QueueStatus;

View File

@@ -1,5 +1,4 @@
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
import PropTypes from 'prop-types';
import React from 'react';
import DocumentTitle from 'react-document-title';
import { Provider } from 'react-redux';
@@ -20,7 +19,7 @@ function App({ store, history }: AppProps) {
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<AppRoutes app={App} />
<AppRoutes />
</PageConnector>
</ConnectedRouter>
</Provider>
@@ -28,9 +27,4 @@ function App({ store, history }: AppProps) {
);
}
App.propTypes = {
store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default App;

View File

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import Blocklist from 'Activity/Blocklist/Blocklist';
@@ -35,6 +34,10 @@ import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
function RedirectWithUrlBase() {
return <Redirect to={getPathWithUrlBase('/')} />;
}
function AppRoutes() {
return (
<Switch>
@@ -51,9 +54,7 @@ function AppRoutes() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
addUrlBase={false}
render={() => {
return <Redirect to={getPathWithUrlBase('/')} />;
}}
render={RedirectWithUrlBase}
/>
)}
@@ -61,21 +62,9 @@ function AppRoutes() {
<Route path="/add/import" component={ImportSeries} />
<Route
path="/serieseditor"
exact={true}
render={() => {
return <Redirect to={getPathWithUrlBase('/')} />;
}}
/>
<Route path="/serieseditor" exact={true} render={RedirectWithUrlBase} />
<Route
path="/seasonpass"
exact={true}
render={() => {
return <Redirect to={getPathWithUrlBase('/')} />;
}}
/>
<Route path="/seasonpass" exact={true} render={RedirectWithUrlBase} />
<Route path="/series/:titleSlug" component={SeriesDetailsPageConnector} />
@@ -175,8 +164,4 @@ function AppRoutes() {
);
}
AppRoutes.propTypes = {
app: PropTypes.func.isRequired,
};
export default AppRoutes;

View File

@@ -1,10 +1,10 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState';
import HistoryAppState from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import ParseAppState from './ParseAppState';
import QueueAppState from './QueueAppState';
import RootFolderAppState from './RootFolderAppState';
@@ -12,6 +12,7 @@ import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState';
import WantedAppState from './WantedAppState';
interface FilterBuilderPropOption {
id: string;
@@ -62,8 +63,8 @@ interface AppState {
blocklist: BlocklistAppState;
calendar: CalendarAppState;
commands: CommandAppState;
episodes: EpisodesAppState;
episodeFiles: EpisodeFilesAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
@@ -75,6 +76,7 @@ interface AppState {
settings: SettingsAppState;
system: SystemAppState;
tags: TagsAppState;
wanted: WantedAppState;
}
export default AppState;

View File

@@ -6,6 +6,7 @@ import AppSectionState, {
PagedAppSectionState,
} from 'App/State/AppSectionState';
import Language from 'Language/Language';
import CustomFormat from 'typings/CustomFormat';
import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import ImportListExclusion from 'typings/ImportListExclusion';
@@ -24,7 +25,9 @@ export interface DownloadClientAppState
isTestingAll: boolean;
}
export type GeneralAppState = AppSectionItemState<General>;
export interface GeneralAppState
extends AppSectionItemState<General>,
AppSectionSaveState {}
export interface ImportListAppState
extends AppSectionState<ImportList>,
@@ -46,6 +49,11 @@ export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionItemSchemaState<QualityProfile> {}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface ImportListOptionsSettingsAppState
extends AppSectionItemState<ImportListOptionsSettings>,
AppSectionSaveState {}
@@ -64,6 +72,7 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState {
advancedSettings: boolean;
customFormats: CustomFormatAppState;
downloadClients: DownloadClientAppState;
general: GeneralAppState;
importListExclusions: ImportListExclusionsSettingsAppState;

View File

@@ -8,15 +8,15 @@ import AppSectionState, { AppSectionItemState } from './AppSectionState';
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type UpdateAppState = AppSectionState<Update>;
export type TaskAppState = AppSectionState<Task>;
export type UpdateAppState = AppSectionState<Update>;
interface SystemAppState {
diskSpace: DiskSpaceAppState;
health: HealthAppState;
updates: UpdateAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
updates: UpdateAppState;
}
export default SystemAppState;

View File

@@ -0,0 +1,13 @@
import AppSectionState from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
interface WantedCutoffUnmetAppState extends AppSectionState<Episode> {}
interface WantedMissingAppState extends AppSectionState<Episode> {}
interface WantedAppState {
cutoffUnmet: WantedCutoffUnmetAppState;
missing: WantedMissingAppState;
}
export default WantedAppState;

View File

@@ -26,7 +26,8 @@ export interface CommandBody {
seriesId?: number;
seriesIds?: number[];
seasonNumber?: number;
[key: string]: string | number | boolean | undefined | number[] | undefined;
episodeIds?: number[];
[key: string]: string | number | boolean | number[] | undefined;
}
interface Command extends ModelBase {

View File

@@ -64,7 +64,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
<div>{info.componentStack}</div>
)}
{<div className={styles.version}>Version: {window.Sonarr.version}</div>}
<div className={styles.version}>Version: {window.Sonarr.version}</div>
</details>
</div>
);

View File

@@ -12,7 +12,7 @@ import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValu
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
@@ -78,7 +78,7 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return QualityFilterBuilderRowValueConnector;
case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValueConnector;
return QualityProfileFilterBuilderRowValue;
case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
return SeasonsMonitoredStatusFilterBuilderRowValue;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createQualityProfilesSelector() {
return createSelector(
(state: AppState) => state.settings.qualityProfiles.items,
(qualityProfiles) => {
return qualityProfiles;
}
);
}
function QualityProfileFilterBuilderRowValue(
props: FilterBuilderRowValueProps
) {
const qualityProfiles = useSelector(createQualityProfilesSelector());
const tagList = qualityProfiles
.map(({ id, name }) => ({ id, name }))
.sort(sortByProp('name'));
return <FilterBuilderRowValue {...props} tagList={tagList} />;
}
export default QualityProfileFilterBuilderRowValue;

View File

@@ -1,28 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const tagList = qualityProfiles.items.map((qualityProfile) => {
const {
id,
name
} = qualityProfile;
return {
id,
name
};
});
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderRowValue);

View File

@@ -46,9 +46,9 @@ function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
const values = [...seriesTypeOptions];
const {
includeNoChange,
includeNoChange = false,
includeNoChangeDisabled = true,
includeMixed,
includeMixed = false,
} = props;
if (includeNoChange) {
@@ -77,9 +77,4 @@ function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
);
}
SeriesTypeSelectInput.defaultProps = {
includeNoChange: false,
includeMixed: false,
};
export default SeriesTypeSelectInput;

View File

@@ -1,73 +0,0 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { PureComponent } from 'react';
import { kinds } from 'Helpers/Props';
import styles from './Icon.css';
class Icon extends PureComponent {
//
// Render
render() {
const {
containerClassName,
className,
name,
kind,
size,
title,
isSpinning,
...otherProps
} = this.props;
const icon = (
<FontAwesomeIcon
className={classNames(
className,
styles[kind]
)}
icon={name}
spin={isSpinning}
style={{
fontSize: `${size}px`
}}
{...otherProps}
/>
);
if (title) {
return (
<span
className={containerClassName}
title={typeof title === 'function' ? title() : title}
>
{icon}
</span>
);
}
return icon;
}
}
Icon.propTypes = {
containerClassName: PropTypes.string,
className: PropTypes.string,
name: PropTypes.object.isRequired,
kind: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
isSpinning: PropTypes.bool.isRequired,
fixedWidth: PropTypes.bool.isRequired
};
Icon.defaultProps = {
kind: kinds.DEFAULT,
size: 14,
isSpinning: false,
fixedWidth: false
};
export default Icon;

View File

@@ -0,0 +1,59 @@
import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import React, { ComponentProps } from 'react';
import { kinds } from 'Helpers/Props';
import styles from './Icon.css';
export interface IconProps
extends Omit<
FontAwesomeIconProps,
'icon' | 'spin' | 'name' | 'title' | 'size'
> {
containerClassName?: ComponentProps<'span'>['className'];
name: FontAwesomeIconProps['icon'];
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
size?: number;
isSpinning?: FontAwesomeIconProps['spin'];
title?: string | (() => string);
}
export default function Icon({
containerClassName,
className,
name,
kind = kinds.DEFAULT,
size = 14,
title,
isSpinning = false,
fixedWidth = false,
...otherProps
}: IconProps) {
const icon = (
<FontAwesomeIcon
className={classNames(className, styles[kind])}
icon={name}
spin={isSpinning}
fixedWidth={fixedWidth}
style={{
fontSize: `${size}px`,
}}
{...otherProps}
/>
);
if (title) {
return (
<span
className={containerClassName}
title={typeof title === 'function' ? title() : title}
>
{icon}
</span>
);
}
return icon;
}

View File

@@ -1,48 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { kinds, sizes } from 'Helpers/Props';
import styles from './Label.css';
function Label(props) {
const {
className,
kind,
size,
outline,
children,
...otherProps
} = props;
return (
<span
className={classNames(
className,
styles[kind],
styles[size],
outline && styles.outline
)}
{...otherProps}
>
{children}
</span>
);
}
Label.propTypes = {
className: PropTypes.string.isRequired,
title: PropTypes.string,
kind: PropTypes.oneOf(kinds.all).isRequired,
size: PropTypes.oneOf(sizes.all).isRequired,
outline: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired
};
Label.defaultProps = {
className: styles.label,
kind: kinds.DEFAULT,
size: sizes.SMALL,
outline: false
};
export default Label;

View File

@@ -0,0 +1,31 @@
import classNames from 'classnames';
import React, { ComponentProps, ReactNode } from 'react';
import { kinds, sizes } from 'Helpers/Props';
import styles from './Label.css';
export interface LabelProps extends ComponentProps<'span'> {
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
size?: Extract<(typeof sizes.all)[number], keyof typeof styles>;
outline?: boolean;
children: ReactNode;
}
export default function Label({
className = styles.label,
kind = kinds.DEFAULT,
size = sizes.SMALL,
outline = false,
...otherProps
}: LabelProps) {
return (
<span
className={classNames(
className,
styles[kind],
styles[size],
outline && styles.outline
)}
{...otherProps}
/>
);
}

View File

@@ -1,54 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { align, kinds, sizes } from 'Helpers/Props';
import Link from './Link';
import styles from './Button.css';
class Button extends Component {
//
// Render
render() {
const {
className,
buttonGroupPosition,
kind,
size,
children,
...otherProps
} = this.props;
return (
<Link
className={classNames(
className,
styles[kind],
styles[size],
buttonGroupPosition && styles[buttonGroupPosition]
)}
{...otherProps}
>
{children}
</Link>
);
}
}
Button.propTypes = {
className: PropTypes.string.isRequired,
buttonGroupPosition: PropTypes.oneOf(align.all),
kind: PropTypes.oneOf(kinds.all),
size: PropTypes.oneOf(sizes.all),
children: PropTypes.node
};
Button.defaultProps = {
className: styles.button,
kind: kinds.DEFAULT,
size: sizes.MEDIUM
};
export default Button;

View File

@@ -0,0 +1,35 @@
import classNames from 'classnames';
import React from 'react';
import { align, kinds, sizes } from 'Helpers/Props';
import Link, { LinkProps } from './Link';
import styles from './Button.css';
export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
buttonGroupPosition?: Extract<
(typeof align.all)[number],
keyof typeof styles
>;
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
size?: Extract<(typeof sizes.all)[number], keyof typeof styles>;
children: Required<LinkProps['children']>;
}
export default function Button({
className = styles.button,
buttonGroupPosition,
kind = kinds.DEFAULT,
size = sizes.MEDIUM,
...otherProps
}: ButtonProps) {
return (
<Link
className={classNames(
className,
styles[kind],
styles[size],
buttonGroupPosition && styles[buttonGroupPosition]
)}
{...otherProps}
/>
);
}

View File

@@ -1,59 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import translate from 'Utilities/String/translate';
import Link from './Link';
import styles from './IconButton.css';
function IconButton(props) {
const {
className,
iconClassName,
name,
kind,
size,
isSpinning,
isDisabled,
...otherProps
} = props;
return (
<Link
className={classNames(
className,
isDisabled && styles.isDisabled
)}
aria-label={translate('TableOptionsButton')}
isDisabled={isDisabled}
{...otherProps}
>
<Icon
className={iconClassName}
name={name}
kind={kind}
size={size}
isSpinning={isSpinning}
/>
</Link>
);
}
IconButton.propTypes = {
...Link.propTypes,
className: PropTypes.string.isRequired,
iconClassName: PropTypes.string,
kind: PropTypes.string,
name: PropTypes.object.isRequired,
size: PropTypes.number,
title: PropTypes.string,
isSpinning: PropTypes.bool,
isDisabled: PropTypes.bool
};
IconButton.defaultProps = {
className: styles.button,
size: 12
};
export default IconButton;

View File

@@ -0,0 +1,41 @@
import classNames from 'classnames';
import React from 'react';
import Icon, { IconProps } from 'Components/Icon';
import translate from 'Utilities/String/translate';
import Link, { LinkProps } from './Link';
import styles from './IconButton.css';
export interface IconButtonProps
extends Omit<LinkProps, 'name' | 'kind'>,
Pick<IconProps, 'name' | 'kind' | 'size' | 'isSpinning'> {
iconClassName?: IconProps['className'];
}
export default function IconButton({
className = styles.button,
iconClassName,
name,
kind,
size = 12,
isSpinning,
...otherProps
}: IconButtonProps) {
return (
<Link
className={classNames(
className,
otherProps.isDisabled && styles.isDisabled
)}
aria-label={translate('TableOptionsButton')}
{...otherProps}
>
<Icon
className={iconClassName}
name={name}
kind={kind}
size={size}
isSpinning={isSpinning}
/>
</Link>
);
}

View File

@@ -1,96 +1,93 @@
import classNames from 'classnames';
import React, {
ComponentClass,
FunctionComponent,
ComponentPropsWithoutRef,
ElementType,
SyntheticEvent,
useCallback,
} from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
interface ReactRouterLinkProps {
to?: string;
}
export type LinkProps<C extends ElementType = 'button'> =
ComponentPropsWithoutRef<C> & {
component?: C;
to?: string;
target?: string;
isDisabled?: LinkProps<C>['disabled'];
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
};
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
className?: string;
component?:
| string
| FunctionComponent<LinkProps>
| ComponentClass<LinkProps, unknown>;
to?: string;
target?: string;
isDisabled?: boolean;
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
}
function Link(props: LinkProps) {
const {
className,
component = 'button',
to,
target,
type,
isDisabled,
noRouter = false,
onPress,
...otherProps
} = props;
export default function Link<C extends ElementType = 'button'>({
className,
component,
to,
target,
type,
isDisabled,
noRouter,
onPress,
...otherProps
}: LinkProps<C>) {
const Component = component || 'button';
const onClick = useCallback(
(event: SyntheticEvent) => {
if (!isDisabled && onPress) {
onPress(event);
if (isDisabled) {
return;
}
onPress?.(event);
},
[isDisabled, onPress]
);
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
target,
};
let el = component;
if (to) {
if (/\w+?:\/\//.test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
el = RouterLink;
linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
}
if (el === 'button' || el === 'input') {
linkProps.type = type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
const linkClass = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const elementProps = {
...otherProps,
type,
...linkProps,
};
if (to) {
const toLink = /\w+?:\/\//.test(to);
elementProps.onClick = onClick;
if (toLink || noRouter) {
return (
<a
href={to}
target={target || (toLink ? '_blank' : '_self')}
rel={toLink ? 'noreferrer' : undefined}
className={linkClass}
onClick={onClick}
{...otherProps}
/>
);
}
return React.createElement(el, elementProps);
return (
<RouterLink
to={`${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`}
target={target}
className={linkClass}
onClick={onClick}
{...otherProps}
/>
);
}
return (
<Component
type={
component === 'button' || component === 'input'
? type || 'button'
: type
}
target={target}
className={linkClass}
disabled={isDisabled}
onClick={onClick}
{...otherProps}
/>
);
}
export default Link;

View File

@@ -3,9 +3,9 @@
padding: 0;
font-size: inherit;
}
.isDisabled {
color: var(--disabledColor);
cursor: not-allowed;
&.isDisabled {
color: var(--disabledColor);
cursor: not-allowed;
}
}

View File

@@ -6,7 +6,7 @@ import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
import SeriesSearchInputConnector from './SeriesSearchInputConnector';
import styles from './PageHeader.css';
@@ -83,7 +83,8 @@ class PageHeader extends Component {
size={14}
title={translate('Donate')}
/>
<PageHeaderActionsMenuConnector
<PageHeaderActionsMenu
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
/>
</div>

View File

@@ -1,90 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
import { align, icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './PageHeaderActionsMenu.css';
function PageHeaderActionsMenu(props) {
const {
formsAuth,
onKeyboardShortcutsPress,
onRestartPress,
onShutdownPress
} = props;
return (
<div>
<Menu alignMenu={align.RIGHT}>
<MenuButton className={styles.menuButton} aria-label="Menu Button">
<Icon
name={icons.INTERACTIVE}
title={translate('Menu')}
/>
</MenuButton>
<MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon
className={styles.itemIcon}
name={icons.KEYBOARD}
/>
{translate('KeyboardShortcuts')}
</MenuItem>
<MenuItemSeparator />
<MenuItem onPress={onRestartPress}>
<Icon
className={styles.itemIcon}
name={icons.RESTART}
/>
{translate('Restart')}
</MenuItem>
<MenuItem onPress={onShutdownPress}>
<Icon
className={styles.itemIcon}
name={icons.SHUTDOWN}
kind={kinds.DANGER}
/>
{translate('Shutdown')}
</MenuItem>
{
formsAuth &&
<div className={styles.separator} />
}
{
formsAuth &&
<MenuItem
to={`${window.Sonarr.urlBase}/logout`}
noRouter={true}
>
<Icon
className={styles.itemIcon}
name={icons.LOGOUT}
/>
{translate('Logout')}
</MenuItem>
}
</MenuContent>
</Menu>
</div>
);
}
PageHeaderActionsMenu.propTypes = {
formsAuth: PropTypes.bool.isRequired,
onKeyboardShortcutsPress: PropTypes.func.isRequired,
onRestartPress: PropTypes.func.isRequired,
onShutdownPress: PropTypes.func.isRequired
};
export default PageHeaderActionsMenu;

View File

@@ -0,0 +1,87 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
import { align, icons, kinds } from 'Helpers/Props';
import { restart, shutdown } from 'Store/Actions/systemActions';
import translate from 'Utilities/String/translate';
import styles from './PageHeaderActionsMenu.css';
interface PageHeaderActionsMenuProps {
onKeyboardShortcutsPress(): void;
}
function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) {
const { onKeyboardShortcutsPress } = props;
const dispatch = useDispatch();
const { authentication, isDocker } = useSelector(
(state: AppState) => state.system.status.item
);
const formsAuth = authentication === 'forms';
const handleRestartPress = useCallback(() => {
dispatch(restart());
}, [dispatch]);
const handleShutdownPress = useCallback(() => {
dispatch(shutdown());
}, [dispatch]);
return (
<div>
<Menu alignMenu={align.RIGHT}>
<MenuButton className={styles.menuButton} aria-label="Menu Button">
<Icon name={icons.INTERACTIVE} title={translate('Menu')} />
</MenuButton>
<MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon className={styles.itemIcon} name={icons.KEYBOARD} />
{translate('KeyboardShortcuts')}
</MenuItem>
{isDocker ? null : (
<>
<MenuItemSeparator />
<MenuItem onPress={handleRestartPress}>
<Icon className={styles.itemIcon} name={icons.RESTART} />
{translate('Restart')}
</MenuItem>
<MenuItem onPress={handleShutdownPress}>
<Icon
className={styles.itemIcon}
name={icons.SHUTDOWN}
kind={kinds.DANGER}
/>
{translate('Shutdown')}
</MenuItem>
</>
)}
{formsAuth ? (
<>
<MenuItemSeparator />
<MenuItem to={`${window.Sonarr.urlBase}/logout`} noRouter={true}>
<Icon className={styles.itemIcon} name={icons.LOGOUT} />
{translate('Logout')}
</MenuItem>
</>
) : null}
</MenuContent>
</Menu>
</div>
);
}
export default PageHeaderActionsMenu;

View File

@@ -1,56 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { restart, shutdown } from 'Store/Actions/systemActions';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
function createMapStateToProps() {
return createSelector(
(state) => state.system.status,
(status) => {
return {
formsAuth: status.item.authentication === 'forms'
};
}
);
}
const mapDispatchToProps = {
restart,
shutdown
};
class PageHeaderActionsMenuConnector extends Component {
//
// Listeners
onRestartPress = () => {
this.props.restart();
};
onShutdownPress = () => {
this.props.shutdown();
};
//
// Render
render() {
return (
<PageHeaderActionsMenu
{...this.props}
onRestartPress={this.onRestartPress}
onShutdownPress={this.onShutdownPress}
/>
);
}
}
PageHeaderActionsMenuConnector.propTypes = {
restart: PropTypes.func.isRequired,
shutdown: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector);

View File

@@ -212,6 +212,8 @@ class SignalRConnector extends Component {
if (action === 'updated') {
this.props.dispatchUpdateItem({ section, ...body.resource });
repopulatePage('seriesUpdated');
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: body.resource.id });
}

View File

@@ -1,34 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons } from 'Helpers/Props';
import Icon from './Icon';
function SpinnerIcon(props) {
const {
name,
spinningName,
isSpinning,
...otherProps
} = props;
return (
<Icon
name={isSpinning ? (spinningName || name) : name}
isSpinning={isSpinning}
{...otherProps}
/>
);
}
SpinnerIcon.propTypes = {
className: PropTypes.string,
name: PropTypes.object.isRequired,
spinningName: PropTypes.object.isRequired,
isSpinning: PropTypes.bool.isRequired
};
SpinnerIcon.defaultProps = {
spinningName: icons.SPINNER
};
export default SpinnerIcon;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { icons } from 'Helpers/Props';
import Icon, { IconProps } from './Icon';
export interface SpinnerIconProps extends IconProps {
spinningName?: IconProps['name'];
isSpinning: Required<IconProps['isSpinning']>;
}
export default function SpinnerIcon({
name,
spinningName = icons.SPINNER,
...otherProps
}: SpinnerIconProps) {
return (
<Icon
name={(otherProps.isSpinning && spinningName) || name}
{...otherProps}
/>
);
}

View File

@@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styles from './TableRowCell.css';
class TableRowCell extends Component {
//
// Render
render() {
const {
className,
children,
...otherProps
} = this.props;
return (
<td
className={className}
{...otherProps}
>
{children}
</td>
);
}
}
TableRowCell.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
};
TableRowCell.defaultProps = {
className: styles.cell
};
export default TableRowCell;

View File

@@ -0,0 +1,11 @@
import React, { ComponentPropsWithoutRef } from 'react';
import styles from './TableRowCell.css';
export interface TableRowCellprops extends ComponentPropsWithoutRef<'td'> {}
export default function TableRowCell({
className = styles.cell,
...tdProps
}: TableRowCellprops) {
return <td className={className} {...tdProps} />;
}

View File

@@ -66,7 +66,9 @@ function Table(props) {
columns.map((column) => {
const {
name,
isVisible
isVisible,
isSortable,
...otherColumnProps
} = column;
if (!isVisible) {
@@ -84,6 +86,7 @@ function Table(props) {
name={name}
isSortable={false}
{...otherProps}
{...otherColumnProps}
>
<TableOptionsModalWrapper
columns={columns}

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/Content/Images/Icons/mstile-150x150.png"/>
<TileColor>#00ccff</TileColor>
</tile>
</msapplication>
</browserconfig>

View File

@@ -1,19 +0,0 @@
{
"name": "Sonarr",
"icons": [
{
"src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "../../../../",
"theme_color": "#3a3f51",
"background_color": "#3a3f51",
"display": "standalone"
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="__URL_BASE__/Content/Images/Icons/mstile-150x150.png" />
<TileColor>
#00ccff
</TileColor>
</tile>
</msapplication>
</browserconfig>

View File

@@ -0,0 +1,19 @@
{
"name": "Sonarr",
"icons": [
{
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "__URL_BASE__/",
"theme_color": "#3a3f51",
"background_color": "#3a3f51",
"display": "standalone"
}

View File

@@ -19,6 +19,7 @@ interface Episode extends ModelBase {
episodeFile?: object;
hasFile: boolean;
monitored: boolean;
grabbed?: boolean;
unverifiedSceneNumbering: boolean;
endTime?: string;
grabDate?: string;

View File

@@ -1,60 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector';
class EpisodeDetailsModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
closeOnBackgroundClick: props.selectedTab !== 'search'
};
}
//
// Listeners
onTabChange = (isSearch) => {
this.setState({ closeOnBackgroundClick: !isSearch });
};
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE}
closeOnBackgroundClick={this.state.closeOnBackgroundClick}
onModalClose={onModalClose}
>
<EpisodeDetailsModalContentConnector
{...otherProps}
onTabChange={this.onTabChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
EpisodeDetailsModal.propTypes = {
selectedTab: PropTypes.string,
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EpisodeDetailsModal;

View File

@@ -0,0 +1,52 @@
import React, { useCallback, useState } from 'react';
import Modal from 'Components/Modal/Modal';
import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab';
import { EpisodeEntities } from 'Episode/useEpisode';
import { sizes } from 'Helpers/Props';
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
interface EpisodeDetailsModalProps {
isOpen: boolean;
episodeId: number;
episodeEntity: EpisodeEntities;
seriesId: number;
episodeTitle: string;
isSaving?: boolean;
showOpenSeriesButton?: boolean;
selectedTab?: EpisodeDetailsTab;
startInteractiveSearch?: boolean;
onModalClose(): void;
}
function EpisodeDetailsModal(props: EpisodeDetailsModalProps) {
const { selectedTab, isOpen, onModalClose, ...otherProps } = props;
const [closeOnBackgroundClick, setCloseOnBackgroundClick] = useState(
selectedTab !== 'search'
);
const handleTabChange = useCallback(
(isSearch: boolean) => {
setCloseOnBackgroundClick(!isSearch);
},
[setCloseOnBackgroundClick]
);
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE}
closeOnBackgroundClick={closeOnBackgroundClick}
onModalClose={onModalClose}
>
<EpisodeDetailsModalContent
{...otherProps}
selectedTab={selectedTab}
onTabChange={handleTabChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default EpisodeDetailsModal;

View File

@@ -1,222 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import episodeEntities from 'Episode/episodeEntities';
import translate from 'Utilities/String/translate';
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
import EpisodeSummaryConnector from './Summary/EpisodeSummaryConnector';
import styles from './EpisodeDetailsModalContent.css';
const tabs = [
'details',
'history',
'search'
];
class EpisodeDetailsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
selectedTab: props.selectedTab
};
}
//
// Listeners
onTabSelect = (index, lastIndex) => {
const selectedTab = tabs[index];
this.props.onTabChange(selectedTab === 'search');
this.setState({ selectedTab });
};
//
// Render
render() {
const {
episodeId,
episodeEntity,
episodeFileId,
seriesId,
seriesTitle,
titleSlug,
seriesMonitored,
seriesType,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
episodeTitle,
airDate,
monitored,
isSaving,
showOpenSeriesButton,
startInteractiveSearch,
onMonitorEpisodePress,
onModalClose
} = this.props;
const seriesLink = `/series/${titleSlug}`;
return (
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
<MonitorToggleButton
className={styles.toggleButton}
id={episodeId}
monitored={monitored}
size={18}
isDisabled={!seriesMonitored}
isSaving={isSaving}
onPress={onMonitorEpisodePress}
/>
<span className={styles.seriesTitle}>
{seriesTitle}
</span>
<span className={styles.separator}>-</span>
<SeasonEpisodeNumber
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
absoluteEpisodeNumber={absoluteEpisodeNumber}
airDate={airDate}
seriesType={seriesType}
/>
<span className={styles.separator}>-</span>
{episodeTitle}
</ModalHeader>
<ModalBody>
<Tabs
className={styles.tabs}
selectedIndex={tabs.indexOf(this.state.selectedTab)}
onSelect={this.onTabSelect}
>
<TabList
className={styles.tabList}
>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('Details')}
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('History')}
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('Search')}
</Tab>
</TabList>
<TabPanel>
<div className={styles.tabContent}>
<EpisodeSummaryConnector
episodeId={episodeId}
episodeEntity={episodeEntity}
episodeFileId={episodeFileId}
seriesId={seriesId}
/>
</div>
</TabPanel>
<TabPanel>
<div className={styles.tabContent}>
<EpisodeHistoryConnector
episodeId={episodeId}
/>
</div>
</TabPanel>
<TabPanel>
{/* Don't wrap in tabContent so we not have a top margin */}
<EpisodeSearchConnector
episodeId={episodeId}
startInteractiveSearch={startInteractiveSearch}
onModalClose={onModalClose}
/>
</TabPanel>
</Tabs>
</ModalBody>
<ModalFooter>
{
showOpenSeriesButton &&
<Button
className={styles.openSeriesButton}
to={seriesLink}
onPress={onModalClose}
>
{translate('OpenSeries')}
</Button>
}
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
EpisodeDetailsModalContent.propTypes = {
episodeId: PropTypes.number.isRequired,
episodeEntity: PropTypes.string.isRequired,
episodeFileId: PropTypes.number,
seriesId: PropTypes.number.isRequired,
seriesTitle: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
seriesMonitored: PropTypes.bool.isRequired,
seriesType: PropTypes.string.isRequired,
seasonNumber: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number,
airDate: PropTypes.string.isRequired,
episodeTitle: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
isSaving: PropTypes.bool,
showOpenSeriesButton: PropTypes.bool,
selectedTab: PropTypes.string.isRequired,
startInteractiveSearch: PropTypes.bool.isRequired,
onMonitorEpisodePress: PropTypes.func.isRequired,
onTabChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
EpisodeDetailsModalContent.defaultProps = {
selectedTab: 'details',
episodeEntity: episodeEntities.EPISODES,
startInteractiveSearch: false
};
export default EpisodeDetailsModalContent;

View File

@@ -0,0 +1,204 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import Episode from 'Episode/Episode';
import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab';
import episodeEntities from 'Episode/episodeEntities';
import useEpisode, { EpisodeEntities } from 'Episode/useEpisode';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
import {
cancelFetchReleases,
clearReleases,
} from 'Store/Actions/releaseActions';
import translate from 'Utilities/String/translate';
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
import EpisodeSummary from './Summary/EpisodeSummary';
import styles from './EpisodeDetailsModalContent.css';
const TABS: EpisodeDetailsTab[] = ['details', 'history', 'search'];
export interface EpisodeDetailsModalContentProps {
episodeId: number;
episodeEntity: EpisodeEntities;
seriesId: number;
episodeTitle: string;
isSaving?: boolean;
showOpenSeriesButton?: boolean;
selectedTab?: EpisodeDetailsTab;
startInteractiveSearch?: boolean;
onTabChange(isSearch: boolean): void;
onModalClose(): void;
}
function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
const {
episodeId,
episodeEntity = episodeEntities.EPISODES,
seriesId,
episodeTitle,
isSaving = false,
showOpenSeriesButton = false,
startInteractiveSearch = false,
selectedTab = 'details',
onTabChange,
onModalClose,
} = props;
const dispatch = useDispatch();
const [currentlySelectedTab, setCurrentlySelectedTab] = useState(selectedTab);
const {
title: seriesTitle,
titleSlug,
monitored: seriesMonitored,
seriesType,
} = useSeries(seriesId) as Series;
const {
episodeFileId,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDate,
monitored,
} = useEpisode(episodeId, episodeEntity) as Episode;
const handleTabSelect = useCallback(
(selectedIndex: number) => {
const tab = TABS[selectedIndex];
onTabChange(tab === 'search');
setCurrentlySelectedTab(tab);
},
[onTabChange]
);
const handleMonitorEpisodePress = useCallback(
(monitored: boolean) => {
dispatch(
toggleEpisodeMonitored({
episodeEntity,
episodeId,
monitored,
})
);
},
[episodeEntity, episodeId, dispatch]
);
useEffect(() => {
return () => {
// Clear pending releases here, so we can reshow the search
// results even after switching tabs.
dispatch(cancelFetchReleases());
dispatch(clearReleases());
};
}, [dispatch]);
const seriesLink = `/series/${titleSlug}`;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
<MonitorToggleButton
id={episodeId}
monitored={monitored}
size={18}
isDisabled={!seriesMonitored}
isSaving={isSaving}
onPress={handleMonitorEpisodePress}
/>
<span className={styles.seriesTitle}>{seriesTitle}</span>
<span className={styles.separator}>-</span>
<SeasonEpisodeNumber
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
absoluteEpisodeNumber={absoluteEpisodeNumber}
airDate={airDate}
seriesType={seriesType}
/>
<span className={styles.separator}>-</span>
{episodeTitle}
</ModalHeader>
<ModalBody>
<Tabs
className={styles.tabs}
selectedIndex={TABS.indexOf(currentlySelectedTab)}
onSelect={handleTabSelect}
>
<TabList className={styles.tabList}>
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
{translate('Details')}
</Tab>
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
{translate('History')}
</Tab>
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
{translate('Search')}
</Tab>
</TabList>
<TabPanel>
<div className={styles.tabContent}>
<EpisodeSummary
episodeId={episodeId}
episodeEntity={episodeEntity}
episodeFileId={episodeFileId}
seriesId={seriesId}
/>
</div>
</TabPanel>
<TabPanel>
<div className={styles.tabContent}>
<EpisodeHistoryConnector episodeId={episodeId} />
</div>
</TabPanel>
<TabPanel>
{/* Don't wrap in tabContent so we not have a top margin */}
<EpisodeSearchConnector
episodeId={episodeId}
startInteractiveSearch={startInteractiveSearch}
onModalClose={onModalClose}
/>
</TabPanel>
</Tabs>
</ModalBody>
<ModalFooter>
{showOpenSeriesButton && (
<Button
className={styles.openSeriesButton}
to={seriesLink}
onPress={onModalClose}
>
{translate('OpenSeries')}
</Button>
)}
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default EpisodeDetailsModalContent;

View File

@@ -1,101 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import episodeEntities from 'Episode/episodeEntities';
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
function createMapStateToProps() {
return createSelector(
createEpisodeSelector(),
createSeriesSelector(),
(episode, series) => {
const {
title: seriesTitle,
titleSlug,
monitored: seriesMonitored,
seriesType
} = series;
return {
seriesTitle,
titleSlug,
seriesMonitored,
seriesType,
...episode
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchCancelFetchReleases() {
dispatch(cancelFetchReleases());
},
dispatchClearReleases() {
dispatch(clearReleases());
},
onMonitorEpisodePress(monitored) {
const {
episodeId,
episodeEntity
} = props;
dispatch(toggleEpisodeMonitored({
episodeEntity,
episodeId,
monitored
}));
}
};
}
class EpisodeDetailsModalContentConnector extends Component {
//
// Lifecycle
componentWillUnmount() {
// Clear pending releases here, so we can reshow the search
// results even after switching tabs.
this.props.dispatchCancelFetchReleases();
this.props.dispatchClearReleases();
}
//
// Render
render() {
const {
dispatchCancelFetchReleases,
dispatchClearReleases,
...otherProps
} = this.props;
return (
<EpisodeDetailsModalContent {...otherProps} />
);
}
}
EpisodeDetailsModalContentConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
episodeEntity: PropTypes.string.isRequired,
seriesId: PropTypes.number.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired
};
EpisodeDetailsModalContentConnector.defaultProps = {
episodeEntity: episodeEntities.EPISODES
};
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector);

View File

@@ -0,0 +1,3 @@
type EpisodeDetailsTab = 'details' | 'history' | 'search';
export default EpisodeDetailsTab;

View File

@@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
function EpisodeFormats({ formats }) {
return (
<div>
{
formats.map((format) => {
return (
<Label
key={format.id}
kind={kinds.INFO}
>
{format.name}
</Label>
);
})
}
</div>
);
}
EpisodeFormats.propTypes = {
formats: PropTypes.arrayOf(PropTypes.object).isRequired
};
EpisodeFormats.defaultProps = {
formats: []
};
export default EpisodeFormats;

View File

@@ -0,0 +1,22 @@
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import CustomFormat from 'typings/CustomFormat';
interface EpisodeFormatsProps {
formats: CustomFormat[];
}
function EpisodeFormats({ formats }: EpisodeFormatsProps) {
return (
<div>
{formats.map(({ id, name }) => (
<Label key={id} kind={kinds.INFO}>
{name}
</Label>
))}
</div>
);
}
export default EpisodeFormats;

View File

@@ -1,18 +1,21 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import Popover from 'Components/Tooltip/Popover';
import { kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import translate from 'Utilities/String/translate';
function EpisodeLanguages(props) {
const {
className,
languages,
isCutoffNotMet
} = props;
interface EpisodeLanguagesProps {
className?: string;
languages: Language[];
isCutoffNotMet?: boolean;
}
if (!languages) {
function EpisodeLanguages(props: EpisodeLanguagesProps) {
const { className, languages, isCutoffNotMet = true } = props;
// TODO: Typescript - Remove once everything is converted
if (!languages || languages.length === 0) {
return null;
}
@@ -41,15 +44,9 @@ function EpisodeLanguages(props) {
title={translate('Languages')}
body={
<ul>
{
languages.map((language) => {
return (
<li key={language.id}>
{language.name}
</li>
);
})
}
{languages.map((language) => (
<li key={language.id}>{language.name}</li>
))}
</ul>
}
position={tooltipPositions.LEFT}
@@ -57,14 +54,4 @@ function EpisodeLanguages(props) {
);
}
EpisodeLanguages.propTypes = {
className: PropTypes.string,
languages: PropTypes.arrayOf(PropTypes.object),
isCutoffNotMet: PropTypes.bool
};
EpisodeLanguages.defaultProps = {
isCutoffNotMet: true
};
export default EpisodeLanguages;

View File

@@ -1,4 +1,4 @@
import React, { Fragment } from 'react';
import React from 'react';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
@@ -82,9 +82,7 @@ function EpisodeNumber(props: EpisodeNumberProps) {
<Popover
anchor={
<span>
{showSeasonNumber && seasonNumber != null && (
<Fragment>{seasonNumber}x</Fragment>
)}
{showSeasonNumber && seasonNumber != null && <>{seasonNumber}x</>}
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
@@ -111,9 +109,7 @@ function EpisodeNumber(props: EpisodeNumberProps) {
/>
) : (
<span>
{showSeasonNumber && seasonNumber != null && (
<Fragment>{seasonNumber}x</Fragment>
)}
{showSeasonNumber && seasonNumber != null && <>{seasonNumber}x</>}
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}

View File

@@ -1,86 +0,0 @@
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 EpisodeDetailsModal from './EpisodeDetailsModal';
import styles from './EpisodeSearchCell.css';
class EpisodeSearchCell extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onManualSearchPress = () => {
this.setState({ isDetailsModalOpen: true });
};
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
};
//
// Render
render() {
const {
episodeId,
seriesId,
episodeTitle,
isSearching,
onSearchPress,
...otherProps
} = this.props;
return (
<TableRowCell className={styles.episodeSearchCell}>
<SpinnerIconButton
name={icons.SEARCH}
isSpinning={isSearching}
onPress={onSearchPress}
title={translate('AutomaticSearch')}
/>
<IconButton
name={icons.INTERACTIVE}
onPress={this.onManualSearchPress}
title={translate('InteractiveSearch')}
/>
<EpisodeDetailsModal
isOpen={this.state.isDetailsModalOpen}
episodeId={episodeId}
seriesId={seriesId}
episodeTitle={episodeTitle}
selectedTab="search"
startInteractiveSearch={true}
onModalClose={this.onDetailsModalClose}
{...otherProps}
/>
</TableRowCell>
);
}
}
EpisodeSearchCell.propTypes = {
episodeId: PropTypes.number.isRequired,
seriesId: PropTypes.number.isRequired,
episodeTitle: PropTypes.string.isRequired,
isSearching: PropTypes.bool.isRequired,
onSearchPress: PropTypes.func.isRequired
};
export default EpisodeSearchCell;

View File

@@ -0,0 +1,75 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { EPISODE_SEARCH } from 'Commands/commandNames';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { EpisodeEntities } from 'Episode/useEpisode';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector';
import translate from 'Utilities/String/translate';
import EpisodeDetailsModal from './EpisodeDetailsModal';
import styles from './EpisodeSearchCell.css';
interface EpisodeSearchCellProps {
episodeId: number;
episodeEntity: EpisodeEntities;
seriesId: number;
episodeTitle: string;
}
function EpisodeSearchCell(props: EpisodeSearchCellProps) {
const { episodeId, episodeEntity, seriesId, episodeTitle } = props;
const executingCommands = useSelector(createExecutingCommandsSelector());
const isSearching = executingCommands.some(({ name, body }) => {
const { episodeIds = [] } = body;
return name === EPISODE_SEARCH && episodeIds.indexOf(episodeId) > -1;
});
const dispatch = useDispatch();
const [isDetailsModalOpen, setDetailsModalOpen, setDetailsModalClosed] =
useModalOpenState(false);
const handleSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: EPISODE_SEARCH,
episodeIds: [episodeId],
})
);
}, [episodeId, dispatch]);
return (
<TableRowCell className={styles.episodeSearchCell}>
<SpinnerIconButton
name={icons.SEARCH}
isSpinning={isSearching}
title={translate('AutomaticSearch')}
onPress={handleSearchPress}
/>
<IconButton
name={icons.INTERACTIVE}
title={translate('InteractiveSearch')}
onPress={setDetailsModalOpen}
/>
<EpisodeDetailsModal
isOpen={isDetailsModalOpen}
episodeId={episodeId}
episodeEntity={episodeEntity}
seriesId={seriesId}
episodeTitle={episodeTitle}
selectedTab="search"
startInteractiveSearch={true}
onModalClose={setDetailsModalClosed}
/>
</TableRowCell>
);
}
export default EpisodeSearchCell;

View File

@@ -1,50 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import { isCommandExecuting } from 'Utilities/Command';
import EpisodeSearchCell from './EpisodeSearchCell';
function createMapStateToProps() {
return createSelector(
(state, { episodeId }) => episodeId,
(state, { sceneSeasonNumber }) => sceneSeasonNumber,
createSeriesSelector(),
createCommandsSelector(),
(episodeId, sceneSeasonNumber, series, commands) => {
const isSearching = commands.some((command) => {
const episodeSearch = command.name === commandNames.EPISODE_SEARCH;
if (!episodeSearch) {
return false;
}
return (
isCommandExecuting(command) &&
command.body.episodeIds.indexOf(episodeId) > -1
);
});
return {
seriesMonitored: series.monitored,
seriesType: series.seriesType,
isSearching
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSearchPress(name, path) {
dispatch(executeCommand({
name: commandNames.EPISODE_SEARCH,
episodeIds: [props.episodeId]
}));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell);

View File

@@ -1,34 +1,44 @@
import PropTypes from 'prop-types';
import React from 'react';
import { useSelector } from 'react-redux';
import QueueDetails from 'Activity/Queue/QueueDetails';
import Icon from 'Components/Icon';
import ProgressBar from 'Components/ProgressBar';
import Episode from 'Episode/Episode';
import useEpisode, { EpisodeEntities } from 'Episode/useEpisode';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds, sizes } from 'Helpers/Props';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate';
import EpisodeQuality from './EpisodeQuality';
import styles from './EpisodeStatus.css';
function EpisodeStatus(props) {
interface EpisodeStatusProps {
episodeId: number;
episodeEntity?: EpisodeEntities;
episodeFileId: number;
}
function EpisodeStatus(props: EpisodeStatusProps) {
const { episodeId, episodeEntity = 'episodes', episodeFileId } = props;
const {
airDateUtc,
monitored,
grabbed,
queueItem,
episodeFile
} = props;
grabbed = false,
} = useEpisode(episodeId, episodeEntity) as Episode;
const queueItem = useSelector(createQueueItemSelectorForHook(episodeId));
const episodeFile = useEpisodeFile(episodeFileId);
const hasEpisodeFile = !!episodeFile;
const isQueued = !!queueItem;
const hasAired = isBefore(airDateUtc);
if (isQueued) {
const {
sizeleft,
size
} = queueItem;
const { sizeleft, size } = queueItem;
const progress = size ? (100 - sizeleft / size * 100) : 0;
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
return (
<div className={styles.center}>
@@ -76,10 +86,7 @@ function EpisodeStatus(props) {
if (!airDateUtc) {
return (
<div className={styles.center}>
<Icon
name={icons.TBA}
title={translate('Tba')}
/>
<Icon name={icons.TBA} title={translate('Tba')} />
</div>
);
}
@@ -109,20 +116,9 @@ function EpisodeStatus(props) {
return (
<div className={styles.center}>
<Icon
name={icons.NOT_AIRED}
title={translate('EpisodeHasNotAired')}
/>
<Icon name={icons.NOT_AIRED} title={translate('EpisodeHasNotAired')} />
</div>
);
}
EpisodeStatus.propTypes = {
airDateUtc: PropTypes.string,
monitored: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
episodeFile: PropTypes.object
};
export default EpisodeStatus;

View File

@@ -1,53 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
import EpisodeStatus from './EpisodeStatus';
function createMapStateToProps() {
return createSelector(
createEpisodeSelector(),
createQueueItemSelector(),
createEpisodeFileSelector(),
(episode, queueItem, episodeFile) => {
const result = _.pick(episode, [
'airDateUtc',
'monitored',
'grabbed'
]);
result.queueItem = queueItem;
result.episodeFile = episodeFile;
return result;
}
);
}
const mapDispatchToProps = {
};
class EpisodeStatusConnector extends Component {
//
// Render
render() {
return (
<EpisodeStatus
{...this.props}
/>
);
}
}
EpisodeStatusConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
episodeFileId: PropTypes.number.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector);

View File

@@ -1,13 +1,14 @@
import React, { useCallback, useState } from 'react';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import { EpisodeEntities } from 'Episode/useEpisode';
import FinaleType from './FinaleType';
import styles from './EpisodeTitleLink.css';
interface EpisodeTitleLinkProps {
episodeId: number;
seriesId: number;
episodeEntity: string;
episodeEntity: EpisodeEntities;
episodeTitle: string;
finaleType?: string;
showOpenSeriesButton: boolean;

View File

@@ -1,130 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import styles from './SceneInfo.css';
function SceneInfo(props) {
const {
seasonNumber,
episodeNumber,
sceneSeasonNumber,
sceneEpisodeNumber,
sceneAbsoluteEpisodeNumber,
alternateTitles,
seriesType
} = props;
const reducedAlternateTitles = alternateTitles.map((alternateTitle) => {
let suffix = '';
const altSceneSeasonNumber = sceneSeasonNumber === undefined ? seasonNumber : sceneSeasonNumber;
const altSceneEpisodeNumber = sceneEpisodeNumber === undefined ? episodeNumber : sceneEpisodeNumber;
const mappingSeasonNumber = alternateTitle.sceneOrigin === 'tvdb' ? seasonNumber : altSceneSeasonNumber;
const altSeasonNumber = (alternateTitle.sceneSeasonNumber !== -1 && alternateTitle.sceneSeasonNumber !== undefined) ? alternateTitle.sceneSeasonNumber : mappingSeasonNumber;
const altEpisodeNumber = alternateTitle.sceneOrigin === 'tvdb' ? episodeNumber : altSceneEpisodeNumber;
if (altEpisodeNumber !== altSceneEpisodeNumber) {
suffix = `S${padNumber(altSeasonNumber, 2)}E${padNumber(altEpisodeNumber, 2)}`;
} else if (altSeasonNumber !== altSceneSeasonNumber) {
suffix = `S${padNumber(altSeasonNumber, 2)}`;
}
return {
alternateTitle,
title: alternateTitle.title,
suffix,
comment: alternateTitle.comment
};
});
const groupedAlternateTitles = _.map(_.groupBy(reducedAlternateTitles, (item) => `${item.title} ${item.suffix}`), (group) => {
return {
title: group[0].title,
suffix: group[0].suffix,
comment: _.uniq(group.map((item) => item.comment)).join('/')
};
});
return (
<DescriptionList className={styles.descriptionList}>
{
sceneSeasonNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Season')}
data={sceneSeasonNumber}
/>
}
{
sceneEpisodeNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Episode')}
data={sceneEpisodeNumber}
/>
}
{
seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Absolute')}
data={sceneAbsoluteEpisodeNumber}
/>
}
{
!!alternateTitles.length &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={groupedAlternateTitles.length === 1 ? translate('Title') : translate('Titles')}
data={
<div>
{
groupedAlternateTitles.map(({ title, suffix, comment }) => {
return (
<div
key={`${title} ${suffix}`}
>
{title}
{
suffix &&
<span> ({suffix})</span>
}
{
comment &&
<span className={styles.comment}> {comment}</span>
}
</div>
);
})
}
</div>
}
/>
}
</DescriptionList>
);
}
SceneInfo.propTypes = {
seasonNumber: PropTypes.number,
episodeNumber: PropTypes.number,
sceneSeasonNumber: PropTypes.number,
sceneEpisodeNumber: PropTypes.number,
sceneAbsoluteEpisodeNumber: PropTypes.number,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
seriesType: PropTypes.string
};
export default SceneInfo;

View File

@@ -0,0 +1,168 @@
import React, { useMemo } from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import { AlternateTitle } from 'Series/Series';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import styles from './SceneInfo.css';
interface SceneInfoProps {
seasonNumber?: number;
episodeNumber?: number;
sceneSeasonNumber?: number;
sceneEpisodeNumber?: number;
sceneAbsoluteEpisodeNumber?: number;
alternateTitles: AlternateTitle[];
seriesType?: string;
}
function SceneInfo(props: SceneInfoProps) {
const {
seasonNumber,
episodeNumber,
sceneSeasonNumber,
sceneEpisodeNumber,
sceneAbsoluteEpisodeNumber,
alternateTitles,
seriesType,
} = props;
const groupedAlternateTitles = useMemo(() => {
const reducedAlternateTitles = alternateTitles.map((alternateTitle) => {
let suffix = '';
const altSceneSeasonNumber =
sceneSeasonNumber === undefined ? seasonNumber : sceneSeasonNumber;
const altSceneEpisodeNumber =
sceneEpisodeNumber === undefined ? episodeNumber : sceneEpisodeNumber;
const mappingSeasonNumber =
alternateTitle.sceneOrigin === 'tvdb'
? seasonNumber
: altSceneSeasonNumber;
const altSeasonNumber =
alternateTitle.sceneSeasonNumber !== -1 &&
alternateTitle.sceneSeasonNumber !== undefined
? alternateTitle.sceneSeasonNumber
: mappingSeasonNumber;
const altEpisodeNumber =
alternateTitle.sceneOrigin === 'tvdb'
? episodeNumber
: altSceneEpisodeNumber;
if (altEpisodeNumber !== altSceneEpisodeNumber) {
suffix = `S${padNumber(altSeasonNumber as number, 2)}E${padNumber(
altEpisodeNumber as number,
2
)}`;
} else if (altSeasonNumber !== altSceneSeasonNumber) {
suffix = `S${padNumber(altSeasonNumber as number, 2)}`;
}
return {
alternateTitle,
title: alternateTitle.title,
suffix,
comment: alternateTitle.comment,
};
});
return Object.values(
reducedAlternateTitles.reduce(
(
acc: Record<
string,
{ title: string; suffix: string; comment: string }
>,
alternateTitle
) => {
const key = alternateTitle.suffix
? `${alternateTitle.title} ${alternateTitle.suffix}`
: alternateTitle.title;
const item = acc[key];
if (item) {
item.comment = alternateTitle.comment
? `${item.comment}/${alternateTitle.comment}`
: item.comment;
} else {
acc[key] = {
title: alternateTitle.title,
suffix: alternateTitle.suffix,
comment: alternateTitle.comment ?? '',
};
}
return acc;
},
{}
)
);
}, [
alternateTitles,
seasonNumber,
episodeNumber,
sceneSeasonNumber,
sceneEpisodeNumber,
]);
return (
<DescriptionList className={styles.descriptionList}>
{sceneSeasonNumber === undefined ? null : (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Season')}
data={sceneSeasonNumber}
/>
)}
{sceneEpisodeNumber === undefined ? null : (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Episode')}
data={sceneEpisodeNumber}
/>
)}
{seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined ? (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Absolute')}
data={sceneAbsoluteEpisodeNumber}
/>
) : null}
{alternateTitles.length ? (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={
groupedAlternateTitles.length === 1
? translate('Title')
: translate('Titles')
}
data={
<div>
{groupedAlternateTitles.map(({ title, suffix, comment }) => {
return (
<div key={`${title} ${suffix}`}>
{title}
{suffix && <span> ({suffix})</span>}
{comment ? (
<span className={styles.comment}> {comment}</span>
) : null}
</div>
);
})}
</div>
}
/>
) : null}
</DescriptionList>
);
}
export default SceneInfo;

View File

@@ -1,28 +1,29 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React from 'react';
import { useSelector } from 'react-redux';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime';
import isInNextWeek from 'Utilities/Date/isInNextWeek';
import isToday from 'Utilities/Date/isToday';
import isTomorrow from 'Utilities/Date/isTomorrow';
import translate from 'Utilities/String/translate';
function EpisodeAiring(props) {
const {
airDateUtc,
network,
shortDateFormat,
showRelativeDates,
timeFormat
} = props;
interface EpisodeAiringProps {
airDateUtc?: string;
network: string;
}
function EpisodeAiring(props: EpisodeAiringProps) {
const { airDateUtc, network } = props;
const { shortDateFormat, showRelativeDates, timeFormat } = useSelector(
createUISettingsSelector()
);
const networkLabel = (
<Label
kind={kinds.INFO}
size={sizes.MEDIUM}
>
<Label kind={kinds.INFO} size={sizes.MEDIUM}>
{network}
</Label>
);
@@ -31,7 +32,8 @@ function EpisodeAiring(props) {
if (!airDateUtc) {
return (
<span>
{translate('AirsTbaOn', { networkLabel: '' })}{networkLabel}
{translate('AirsTbaOn', { networkLabel: '' })}
{networkLabel}
</span>
);
}
@@ -41,7 +43,12 @@ function EpisodeAiring(props) {
if (!showRelativeDates) {
return (
<span>
{translate('AirsDateAtTimeOn', { date: moment(airDateUtc).format(shortDateFormat), time, networkLabel: '' })}{networkLabel}
{translate('AirsDateAtTimeOn', {
date: moment(airDateUtc).format(shortDateFormat),
time,
networkLabel: '',
})}
{networkLabel}
</span>
);
}
@@ -49,7 +56,8 @@ function EpisodeAiring(props) {
if (isToday(airDateUtc)) {
return (
<span>
{translate('AirsTimeOn', { time, networkLabel: '' })}{networkLabel}
{translate('AirsTimeOn', { time, networkLabel: '' })}
{networkLabel}
</span>
);
}
@@ -57,7 +65,8 @@ function EpisodeAiring(props) {
if (isTomorrow(airDateUtc)) {
return (
<span>
{translate('AirsTomorrowOn', { time, networkLabel: '' })}{networkLabel}
{translate('AirsTomorrowOn', { time, networkLabel: '' })}
{networkLabel}
</span>
);
}
@@ -65,24 +74,26 @@ function EpisodeAiring(props) {
if (isInNextWeek(airDateUtc)) {
return (
<span>
{translate('AirsDateAtTimeOn', { date: moment(airDateUtc).format('dddd'), time, networkLabel: '' })}{networkLabel}
{translate('AirsDateAtTimeOn', {
date: moment(airDateUtc).format('dddd'),
time,
networkLabel: '',
})}
{networkLabel}
</span>
);
}
return (
<span>
{translate('AirsDateAtTimeOn', { date: moment(airDateUtc).format(shortDateFormat), time, networkLabel: '' })}{networkLabel}
{translate('AirsDateAtTimeOn', {
date: moment(airDateUtc).format(shortDateFormat),
time,
networkLabel: '',
})}
{networkLabel}
</span>
);
}
EpisodeAiring.propTypes = {
airDateUtc: PropTypes.string.isRequired,
network: PropTypes.string.isRequired,
shortDateFormat: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default EpisodeAiring;

View File

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

View File

@@ -1,205 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import MediaInfo from './MediaInfo';
import styles from './EpisodeFileRow.css';
class EpisodeFileRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isRemoveEpisodeFileModalOpen: false
};
}
//
// Listeners
onRemoveEpisodeFilePress = () => {
this.setState({ isRemoveEpisodeFileModalOpen: true });
};
onConfirmRemoveEpisodeFile = () => {
this.props.onDeleteEpisodeFile();
this.setState({ isRemoveEpisodeFileModalOpen: false });
};
onRemoveEpisodeFileModalClose = () => {
this.setState({ isRemoveEpisodeFileModalOpen: false });
};
//
// Render
render() {
const {
path,
size,
languages,
quality,
customFormats,
customFormatScore,
qualityCutoffNotMet,
mediaInfo,
columns
} = this.props;
return (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'path') {
return (
<TableRowCell key={name}>
{path}
</TableRowCell>
);
}
if (name === 'size') {
return (
<TableRowCell key={name}>
{formatBytes(size)}
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell
key={name}
className={styles.languages}
>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell
key={name}
className={styles.quality}
>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell
key={name}
className={styles.customFormats}
>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
);
}
if (name === 'actions') {
return (
<TableRowCell
key={name}
className={styles.actions}
>
{
mediaInfo ?
<Popover
anchor={
<Icon
name={icons.MEDIA_INFO}
/>
}
title={translate('MediaInfo')}
body={<MediaInfo {...mediaInfo} />}
position={tooltipPositions.LEFT}
/> :
null
}
<IconButton
title={translate('DeleteEpisodeFromDisk')}
name={icons.REMOVE}
onPress={this.onRemoveEpisodeFilePress}
/>
</TableRowCell>
);
}
return null;
})
}
<ConfirmModal
isOpen={this.state.isRemoveEpisodeFileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteEpisodeFile')}
message={translate('DeleteEpisodeFileMessage', { path })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmRemoveEpisodeFile}
onCancel={this.onRemoveEpisodeFileModalClose}
/>
</TableRow>
);
}
}
EpisodeFileRow.propTypes = {
path: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
mediaInfo: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onDeleteEpisodeFile: PropTypes.func.isRequired
};
export default EpisodeFileRow;

View File

@@ -0,0 +1,149 @@
import React, { useCallback } from 'react';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import MediaInfo from './MediaInfo';
import styles from './EpisodeFileRow.css';
interface EpisodeFileRowProps {
path: string;
size: number;
languages: Language[];
quality: QualityModel;
qualityCutoffNotMet: boolean;
customFormats: CustomFormat[];
customFormatScore: number;
mediaInfo: object;
columns: Column[];
onDeleteEpisodeFile(): void;
}
function EpisodeFileRow(props: EpisodeFileRowProps) {
const {
path,
size,
languages,
quality,
customFormats,
customFormatScore,
qualityCutoffNotMet,
mediaInfo,
columns,
onDeleteEpisodeFile,
} = props;
const [
isRemoveEpisodeFileModalOpen,
setRemoveEpisodeFileModalOpen,
setRemoveEpisodeFileModalClosed,
] = useModalOpenState(false);
const handleRemoveEpisodeFilePress = useCallback(() => {
onDeleteEpisodeFile();
setRemoveEpisodeFileModalClosed();
}, [onDeleteEpisodeFile, setRemoveEpisodeFileModalClosed]);
return (
<TableRow>
{columns.map(({ name, isVisible }) => {
if (!isVisible) {
return null;
}
if (name === 'path') {
return <TableRowCell key={name}>{path}</TableRowCell>;
}
if (name === 'size') {
return <TableRowCell key={name}>{formatBytes(size)}</TableRowCell>;
}
if (name === 'languages') {
return (
<TableRowCell key={name} className={styles.languages}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name} className={styles.quality}>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name} className={styles.customFormats}>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell key={name} className={styles.customFormatScore}>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
);
}
if (name === 'actions') {
return (
<TableRowCell key={name} className={styles.actions}>
{mediaInfo ? (
<Popover
anchor={<Icon name={icons.MEDIA_INFO} />}
title={translate('MediaInfo')}
body={<MediaInfo {...mediaInfo} />}
position={tooltipPositions.LEFT}
/>
) : null}
<IconButton
title={translate('DeleteEpisodeFromDisk')}
name={icons.REMOVE}
onPress={setRemoveEpisodeFileModalOpen}
/>
</TableRowCell>
);
}
return null;
})}
<ConfirmModal
isOpen={isRemoveEpisodeFileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteEpisodeFile')}
message={translate('DeleteEpisodeFileMessage', { path })}
confirmLabel={translate('Delete')}
onConfirm={handleRemoveEpisodeFilePress}
onCancel={setRemoveEpisodeFileModalClosed}
/>
</TableRow>
);
}
export default EpisodeFileRow;

View File

@@ -1,198 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds, sizes } from 'Helpers/Props';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import translate from 'Utilities/String/translate';
import EpisodeAiringConnector from './EpisodeAiringConnector';
import EpisodeFileRow from './EpisodeFileRow';
import styles from './EpisodeSummary.css';
const columns = [
{
name: 'path',
label: () => translate('Path'),
isSortable: false,
isVisible: true
},
{
name: 'size',
label: () => translate('Size'),
isSortable: false,
isVisible: true
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: false,
isVisible: true
},
{
name: 'quality',
label: () => translate('Quality'),
isSortable: false,
isVisible: true
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isSortable: true,
isVisible: true
},
{
name: 'actions',
label: '',
isSortable: false,
isVisible: true
}
];
class EpisodeSummary extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isRemoveEpisodeFileModalOpen: false
};
}
//
// Listeners
onRemoveEpisodeFilePress = () => {
this.setState({ isRemoveEpisodeFileModalOpen: true });
};
onConfirmRemoveEpisodeFile = () => {
this.props.onDeleteEpisodeFile();
this.setState({ isRemoveEpisodeFileModalOpen: false });
};
onRemoveEpisodeFileModalClose = () => {
this.setState({ isRemoveEpisodeFileModalOpen: false });
};
//
// Render
render() {
const {
qualityProfileId,
network,
overview,
airDateUtc,
mediaInfo,
path,
size,
languages,
quality,
customFormats,
customFormatScore,
qualityCutoffNotMet,
onDeleteEpisodeFile
} = this.props;
const hasOverview = !!overview;
return (
<div>
<div>
<span className={styles.infoTitle}>{translate('Airs')}</span>
<EpisodeAiringConnector
airDateUtc={airDateUtc}
network={network}
/>
</div>
<div>
<span className={styles.infoTitle}>{translate('QualityProfile')}</span>
<Label
kind={kinds.PRIMARY}
size={sizes.MEDIUM}
>
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
</Label>
</div>
<div className={styles.overview}>
{
hasOverview ?
overview :
translate('NoEpisodeOverview')
}
</div>
{
path ?
<Table columns={columns}>
<TableBody>
<EpisodeFileRow
path={path}
size={size}
languages={languages}
quality={quality}
qualityCutoffNotMet={qualityCutoffNotMet}
customFormats={customFormats}
customFormatScore={customFormatScore}
mediaInfo={mediaInfo}
columns={columns}
onDeleteEpisodeFile={onDeleteEpisodeFile}
/>
</TableBody>
</Table> :
null
}
<ConfirmModal
isOpen={this.state.isRemoveEpisodeFileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteEpisodeFile')}
message={translate('DeleteEpisodeFileMessage', { path })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmRemoveEpisodeFile}
onCancel={this.onRemoveEpisodeFileModalClose}
/>
</div>
);
}
}
EpisodeSummary.propTypes = {
episodeFileId: PropTypes.number.isRequired,
qualityProfileId: PropTypes.number.isRequired,
network: PropTypes.string.isRequired,
overview: PropTypes.string,
airDateUtc: PropTypes.string.isRequired,
mediaInfo: PropTypes.object,
path: PropTypes.string,
size: PropTypes.number,
languages: PropTypes.arrayOf(PropTypes.object),
quality: PropTypes.object,
qualityCutoffNotMet: PropTypes.bool,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
onDeleteEpisodeFile: PropTypes.func.isRequired
};
export default EpisodeSummary;

View File

@@ -0,0 +1,161 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import Episode from 'Episode/Episode';
import useEpisode, { EpisodeEntities } from 'Episode/useEpisode';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import {
deleteEpisodeFile,
fetchEpisodeFile,
} from 'Store/Actions/episodeFileActions';
import translate from 'Utilities/String/translate';
import EpisodeAiring from './EpisodeAiring';
import EpisodeFileRow from './EpisodeFileRow';
import styles from './EpisodeSummary.css';
const COLUMNS: Column[] = [
{
name: 'path',
label: () => translate('Path'),
isSortable: false,
isVisible: true,
},
{
name: 'size',
label: () => translate('Size'),
isSortable: false,
isVisible: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: false,
isVisible: true,
},
{
name: 'quality',
label: () => translate('Quality'),
isSortable: false,
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true,
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'actions',
label: '',
isSortable: false,
isVisible: true,
},
];
interface EpisodeSummaryProps {
seriesId: number;
episodeId: number;
episodeEntity: EpisodeEntities;
episodeFileId?: number;
}
function EpisodeSummary(props: EpisodeSummaryProps) {
const { seriesId, episodeId, episodeEntity, episodeFileId } = props;
const dispatch = useDispatch();
const { qualityProfileId, network } = useSeries(seriesId) as Series;
const { airDateUtc, overview } = useEpisode(
episodeId,
episodeEntity
) as Episode;
const {
path,
mediaInfo,
size,
languages,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore,
} = useEpisodeFile(episodeFileId) || {};
const handleDeleteEpisodeFile = useCallback(() => {
dispatch(
deleteEpisodeFile({
id: episodeFileId,
episodeEntity,
})
);
}, [episodeFileId, episodeEntity, dispatch]);
useEffect(() => {
if (episodeFileId && !path) {
dispatch(fetchEpisodeFile({ id: episodeFileId }));
}
}, [episodeFileId, path, dispatch]);
const hasOverview = !!overview;
return (
<div>
<div>
<span className={styles.infoTitle}>{translate('Airs')}</span>
<EpisodeAiring airDateUtc={airDateUtc} network={network} />
</div>
<div>
<span className={styles.infoTitle}>{translate('QualityProfile')}</span>
<Label kind={kinds.PRIMARY} size={sizes.MEDIUM}>
<QualityProfileNameConnector qualityProfileId={qualityProfileId} />
</Label>
</div>
<div className={styles.overview}>
{hasOverview ? overview : translate('NoEpisodeOverview')}
</div>
{path ? (
<Table columns={COLUMNS}>
<TableBody>
<EpisodeFileRow
path={path}
size={size!}
languages={languages!}
quality={quality!}
qualityCutoffNotMet={qualityCutoffNotMet!}
customFormats={customFormats!}
customFormatScore={customFormatScore!}
mediaInfo={mediaInfo!}
columns={COLUMNS}
onDeleteEpisodeFile={handleDeleteEpisodeFile}
/>
</TableBody>
</Table>
) : null}
</div>
);
}
export default EpisodeSummary;

View File

@@ -1,109 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteEpisodeFile, fetchEpisodeFile } from 'Store/Actions/episodeFileActions';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import EpisodeSummary from './EpisodeSummary';
function createMapStateToProps() {
return createSelector(
createSeriesSelector(),
createEpisodeSelector(),
createEpisodeFileSelector(),
(series, episode, episodeFile = {}) => {
const {
qualityProfileId,
network
} = series;
const {
airDateUtc,
overview
} = episode;
const {
mediaInfo,
path,
size,
languages,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore
} = episodeFile;
return {
network,
qualityProfileId,
airDateUtc,
overview,
mediaInfo,
path,
size,
languages,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onDeleteEpisodeFile() {
dispatch(deleteEpisodeFile({
id: props.episodeFileId,
episodeEntity: props.episodeEntity
}));
},
dispatchFetchEpisodeFile() {
dispatch(fetchEpisodeFile({
id: props.episodeFileId
}));
}
};
}
class EpisodeSummaryConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
episodeFileId,
path,
dispatchFetchEpisodeFile
} = this.props;
if (episodeFileId && !path) {
dispatchFetchEpisodeFile({ id: episodeFileId });
}
}
//
// Render
render() {
const {
dispatchFetchEpisodeFile,
...otherProps
} = this.props;
return <EpisodeSummary {...otherProps} />;
}
}
EpisodeSummaryConnector.propTypes = {
episodeFileId: PropTypes.number,
path: PropTypes.string,
dispatchFetchEpisodeFile: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSummaryConnector);

View File

@@ -9,5 +9,5 @@ export default {
EPISODES,
INTERACTIVE_IMPORT,
WANTED_CUTOFF_UNMET,
WANTED_MISSING
};
WANTED_MISSING,
} as const;

View File

@@ -5,15 +5,15 @@ import AppState from 'App/State/AppState';
export type EpisodeEntities =
| 'calendar'
| 'episodes'
| 'interactiveImport'
| 'cutoffUnmet'
| 'missing';
| 'interactiveImport.episodes'
| 'wanted.cutoffUnmet'
| 'wanted.missing';
function createEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.episodes.items,
(episodes) => {
return episodes.find((e) => e.id === episodeId);
return episodes.find(({ id }) => id === episodeId);
}
);
}
@@ -22,7 +22,25 @@ function createCalendarEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.calendar.items,
(episodes) => {
return episodes.find((e) => e.id === episodeId);
return episodes.find(({ id }) => id === episodeId);
}
);
}
function createWantedCutoffUnmetEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.wanted.cutoffUnmet.items,
(episodes) => {
return episodes.find(({ id }) => id === episodeId);
}
);
}
function createWantedMissingEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.wanted.missing.items,
(episodes) => {
return episodes.find(({ id }) => id === episodeId);
}
);
}
@@ -37,6 +55,12 @@ function useEpisode(
case 'calendar':
selector = createCalendarEpisodeSelector;
break;
case 'wanted.cutoffUnmet':
selector = createWantedCutoffUnmetEpisodeSelector;
break;
case 'wanted.missing':
selector = createWantedMissingEpisodeSelector;
break;
default:
break;
}

View File

@@ -17,6 +17,7 @@ export interface EpisodeFile extends ModelBase {
languages: Language[];
quality: QualityModel;
customFormats: CustomFormat[];
customFormatScore: number;
indexerFlags: number;
releaseType: ReleaseType;
mediaInfo: MediaInfo;

View File

@@ -0,0 +1,18 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createEpisodeFileSelector(episodeFileId?: number) {
return createSelector(
(state: AppState) => state.episodeFiles.items,
(episodeFiles) => {
return episodeFiles.find(({ id }) => id === episodeFileId);
}
);
}
function useEpisodeFile(episodeFileId: number | undefined) {
return useSelector(createEpisodeFileSelector(episodeFileId));
}
export default useEpisodeFile;

View File

@@ -1,34 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import AuthenticationRequiredModalContentConnector from './AuthenticationRequiredModalContentConnector';
function onModalClose() {
// No-op
}
function AuthenticationRequiredModal(props) {
const {
isOpen
} = props;
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<AuthenticationRequiredModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
AuthenticationRequiredModal.propTypes = {
isOpen: PropTypes.bool.isRequired
};
export default AuthenticationRequiredModal;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import AuthenticationRequiredModalContent from './AuthenticationRequiredModalContent';
function onModalClose() {
// No-op
}
interface AuthenticationRequiredModalProps {
isOpen: boolean;
}
export default function AuthenticationRequiredModal({
isOpen,
}: AuthenticationRequiredModalProps) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<AuthenticationRequiredModalContent />
</Modal>
);
}

View File

@@ -1,170 +0,0 @@
import PropTypes from 'prop-types';
import React, { useEffect, useRef } from 'react';
import Alert from 'Components/Alert';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import { authenticationMethodOptions, authenticationRequiredOptions } from 'Settings/General/SecuritySettings';
import translate from 'Utilities/String/translate';
import styles from './AuthenticationRequiredModalContent.css';
function onModalClose() {
// No-op
}
function AuthenticationRequiredModalContent(props) {
const {
isPopulated,
error,
isSaving,
settings,
onInputChange,
onSavePress,
dispatchFetchStatus
} = props;
const {
authenticationMethod,
authenticationRequired,
username,
password,
passwordConfirmation
} = settings;
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
const didMount = useRef(false);
useEffect(() => {
if (!isSaving && didMount.current) {
dispatchFetchStatus();
}
didMount.current = true;
}, [isSaving, dispatchFetchStatus]);
return (
<ModalContent
showCloseButton={false}
onModalClose={onModalClose}
>
<ModalHeader>
{translate('AuthenticationRequired')}
</ModalHeader>
<ModalBody>
<Alert
className={styles.authRequiredAlert}
kind={kinds.WARNING}
>
{translate('AuthenticationRequiredWarning')}
</Alert>
{
isPopulated && !error ?
<div>
<FormGroup>
<FormLabel>{translate('AuthenticationMethod')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="authenticationMethod"
values={authenticationMethodOptions}
helpText={translate('AuthenticationMethodHelpText')}
helpTextWarning={authenticationMethod.value === 'none' ? translate('AuthenticationMethodHelpTextWarning') : undefined}
helpLink="https://wiki.servarr.com/sonarr/faq#forced-authentication"
onChange={onInputChange}
{...authenticationMethod}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('AuthenticationRequired')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="authenticationRequired"
values={authenticationRequiredOptions}
helpText={translate('AuthenticationRequiredHelpText')}
onChange={onInputChange}
{...authenticationRequired}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Username')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="username"
onChange={onInputChange}
helpTextWarning={username?.value ? undefined : translate('AuthenticationRequiredUsernameHelpTextWarning')}
{...username}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Password')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="password"
onChange={onInputChange}
helpTextWarning={password?.value ? undefined : translate('AuthenticationRequiredPasswordHelpTextWarning')}
{...password}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('PasswordConfirmation')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="passwordConfirmation"
onChange={onInputChange}
helpTextWarning={passwordConfirmation?.value ? undefined : translate('AuthenticationRequiredPasswordConfirmationHelpTextWarning')}
{...passwordConfirmation}
/>
</FormGroup>
</div> :
null
}
{
!isPopulated && !error ? <LoadingIndicator /> : null
}
</ModalBody>
<ModalFooter>
<SpinnerButton
kind={kinds.PRIMARY}
isSpinning={isSaving}
isDisabled={!authenticationEnabled}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
AuthenticationRequiredModalContent.propTypes = {
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
settings: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
dispatchFetchStatus: PropTypes.func.isRequired
};
export default AuthenticationRequiredModalContent;

View File

@@ -0,0 +1,194 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes, kinds } from 'Helpers/Props';
import {
authenticationMethodOptions,
authenticationRequiredOptions,
} from 'Settings/General/SecuritySettings';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
fetchGeneralSettings,
saveGeneralSettings,
setGeneralSettingsValue,
} from 'Store/Actions/settingsActions';
import { fetchStatus } from 'Store/Actions/systemActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './AuthenticationRequiredModalContent.css';
const SECTION = 'general';
const selector = createSettingsSectionSelector(SECTION);
function onModalClose() {
// No-op
}
export default function AuthenticationRequiredModalContent() {
const { isPopulated, error, isSaving, settings } = useSelector(selector);
const dispatch = useDispatch();
const {
authenticationMethod,
authenticationRequired,
username,
password,
passwordConfirmation,
} = settings;
const wasSaving = usePrevious(isSaving);
useEffect(() => {
dispatch(fetchGeneralSettings());
return () => {
dispatch(clearPendingChanges());
};
}, [dispatch]);
const onInputChange = useCallback(
(args: InputChanged) => {
// @ts-expect-error Actions aren't typed
dispatch(setGeneralSettingsValue(args));
},
[dispatch]
);
const authenticationEnabled =
authenticationMethod && authenticationMethod.value !== 'none';
useEffect(() => {
if (isSaving || !wasSaving) {
return;
}
dispatch(fetchStatus());
}, [isSaving, wasSaving, dispatch]);
const onPress = useCallback(() => {
dispatch(saveGeneralSettings());
}, [dispatch]);
return (
<ModalContent showCloseButton={false} onModalClose={onModalClose}>
<ModalHeader>{translate('AuthenticationRequired')}</ModalHeader>
<ModalBody>
<Alert className={styles.authRequiredAlert} kind={kinds.WARNING}>
{translate('AuthenticationRequiredWarning')}
</Alert>
{isPopulated && !error ? (
<div>
<FormGroup>
<FormLabel>{translate('AuthenticationMethod')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="authenticationMethod"
values={authenticationMethodOptions}
helpText={translate('AuthenticationMethodHelpText')}
helpTextWarning={
authenticationMethod.value === 'none'
? translate('AuthenticationMethodHelpTextWarning')
: undefined
}
helpLink="https://wiki.servarr.com/sonarr/faq#forced-authentication"
onChange={onInputChange}
{...authenticationMethod}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('AuthenticationRequired')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="authenticationRequired"
values={authenticationRequiredOptions}
helpText={translate('AuthenticationRequiredHelpText')}
onChange={onInputChange}
{...authenticationRequired}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Username')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="username"
helpTextWarning={
username?.value
? undefined
: translate('AuthenticationRequiredUsernameHelpTextWarning')
}
onChange={onInputChange}
{...username}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Password')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="password"
helpTextWarning={
password?.value
? undefined
: translate('AuthenticationRequiredPasswordHelpTextWarning')
}
onChange={onInputChange}
{...password}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('PasswordConfirmation')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="passwordConfirmation"
helpTextWarning={
passwordConfirmation?.value
? undefined
: translate(
'AuthenticationRequiredPasswordConfirmationHelpTextWarning'
)
}
onChange={onInputChange}
{...passwordConfirmation}
/>
</FormGroup>
</div>
) : null}
{!isPopulated && !error ? <LoadingIndicator /> : null}
</ModalBody>
<ModalFooter>
<SpinnerButton
kind={kinds.PRIMARY}
isSpinning={isSaving}
isDisabled={!authenticationEnabled}
onPress={onPress}
>
{translate('Save')}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}

View File

@@ -1,86 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { fetchGeneralSettings, saveGeneralSettings, setGeneralSettingsValue } from 'Store/Actions/settingsActions';
import { fetchStatus } from 'Store/Actions/systemActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import AuthenticationRequiredModalContent from './AuthenticationRequiredModalContent';
const SECTION = 'general';
function createMapStateToProps() {
return createSelector(
createSettingsSectionSelector(SECTION),
(sectionSettings) => {
return {
...sectionSettings
};
}
);
}
const mapDispatchToProps = {
dispatchClearPendingChanges: clearPendingChanges,
dispatchSetGeneralSettingsValue: setGeneralSettingsValue,
dispatchSaveGeneralSettings: saveGeneralSettings,
dispatchFetchGeneralSettings: fetchGeneralSettings,
dispatchFetchStatus: fetchStatus
};
class AuthenticationRequiredModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchGeneralSettings();
}
componentWillUnmount() {
this.props.dispatchClearPendingChanges({ section: `settings.${SECTION}` });
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.dispatchSetGeneralSettingsValue({ name, value });
};
onSavePress = () => {
this.props.dispatchSaveGeneralSettings();
};
//
// Render
render() {
const {
dispatchClearPendingChanges,
dispatchFetchGeneralSettings,
dispatchSetGeneralSettingsValue,
dispatchSaveGeneralSettings,
...otherProps
} = this.props;
return (
<AuthenticationRequiredModalContent
{...otherProps}
onInputChange={this.onInputChange}
onSavePress={this.onSavePress}
/>
);
}
}
AuthenticationRequiredModalContentConnector.propTypes = {
dispatchClearPendingChanges: PropTypes.func.isRequired,
dispatchFetchGeneralSettings: PropTypes.func.isRequired,
dispatchSetGeneralSettingsValue: PropTypes.func.isRequired,
dispatchSaveGeneralSettings: PropTypes.func.isRequired,
dispatchFetchStatus: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AuthenticationRequiredModalContentConnector);

View File

@@ -3,15 +3,15 @@ import { useCallback, useState } from 'react';
export default function useModalOpenState(
initialState: boolean
): [boolean, () => void, () => void] {
const [isOpen, setOpen] = useState(initialState);
const [isOpen, setIsOpen] = useState(initialState);
const setModalOpen = useCallback(() => {
setOpen(true);
}, [setOpen]);
setIsOpen(true);
}, [setIsOpen]);
const setModalClosed = useCallback(() => {
setOpen(false);
}, [setOpen]);
setIsOpen(false);
}, [setIsOpen]);
return [isOpen, setModalOpen, setModalClosed];
}

View File

@@ -16,7 +16,7 @@ import {
faKeyboard as farKeyboard,
faObjectGroup as farObjectGroup,
faObjectUngroup as farObjectUngroup,
faSquare as farSquare
faSquare as farSquare,
} from '@fortawesome/free-regular-svg-icons';
//
// Solid
@@ -107,7 +107,7 @@ import {
faUser as fasUser,
faUserPlus as fasUserPlus,
faVial as fasVial,
faWrench as fasWrench
faWrench as fasWrench,
} from '@fortawesome/free-solid-svg-icons';
//

View File

@@ -19,5 +19,5 @@ export const all = [
PRIMARY,
PURPLE,
SUCCESS,
WARNING
];
WARNING,
] as const;

View File

@@ -4,4 +4,12 @@ export const MEDIUM = 'medium';
export const LARGE = 'large';
export const EXTRA_LARGE = 'extraLarge';
export const EXTRA_EXTRA_LARGE = 'extraExtraLarge';
export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE, EXTRA_EXTRA_LARGE];
export const all = [
EXTRA_SMALL,
SMALL,
MEDIUM,
LARGE,
EXTRA_LARGE,
EXTRA_EXTRA_LARGE,
] as const;

View File

@@ -857,7 +857,7 @@ function InteractiveImportModalContent(
<MenuContent>
<SelectedMenuItem
name={'all'}
name="all"
isSelected={!filterExistingFiles}
onPress={onFilterExistingFilesChange}
>
@@ -865,7 +865,7 @@ function InteractiveImportModalContent(
</SelectedMenuItem>
<SelectedMenuItem
name={'new'}
name="new"
isSelected={filterExistingFiles}
onPress={onFilterExistingFilesChange}
>
@@ -945,7 +945,7 @@ function InteractiveImportModalContent(
<SelectInput
className={styles.bulkSelect}
name="select"
value={'select'}
value="select"
values={bulkSelectOptions}
isDisabled={!selectedIds.length}
onChange={onSelectModalSelect}

View File

@@ -32,6 +32,7 @@ import {
reprocessInteractiveImportItems,
updateInteractiveImportItem,
} from 'Store/Actions/interactiveImportActions';
import CustomFormat from 'typings/CustomFormat';
import { SelectStateInputProps } from 'typings/props';
import Rejection from 'typings/Rejection';
import formatBytes from 'Utilities/Number/formatBytes';
@@ -66,7 +67,7 @@ interface InteractiveImportRowProps {
languages?: Language[];
size: number;
releaseType: ReleaseType;
customFormats?: object[];
customFormats?: CustomFormat[];
customFormatScore?: number;
indexerFlags: number;
rejections: Rejection[];
@@ -92,7 +93,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
releaseGroup,
size,
releaseType,
customFormats,
customFormats = [],
customFormatScore,
indexerFlags,
rejections,
@@ -525,7 +526,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
<>
{indexerFlags ? (
<Popover
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
anchor={<Icon name={icons.FLAG} />}
title={translate('IndexerFlags')}
body={<IndexerFlags indexerFlags={indexerFlags} />}
position={tooltipPositions.LEFT}

View File

@@ -4,6 +4,7 @@ import ReleaseType from 'InteractiveImport/ReleaseType';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
import CustomFormat from 'typings/CustomFormat';
import Rejection from 'typings/Rejection';
export interface InteractiveImportCommandOptions {
@@ -33,7 +34,7 @@ interface InteractiveImport extends ModelBase {
seasonNumber: number;
episodes: Episode[];
qualityWeight: number;
customFormats: object[];
customFormats: CustomFormat[];
indexerFlags: number;
releaseType: ReleaseType;
rejections: Rejection[];

View File

@@ -17,7 +17,7 @@ function SelectLanguageModal(props: SelectLanguageModalProps) {
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
<SelectLanguageModalContent
languageIds={languageIds}
modalTitle={modalTitle}

View File

@@ -64,19 +64,20 @@ interface RowItemData {
onSeriesSelect(seriesId: number): void;
}
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
const { items, columns, onSeriesSelect } = data;
const series = index >= items.length ? null : items[index];
if (index >= items.length) {
const handlePress = useCallback(() => {
if (series?.id) {
onSeriesSelect(series.id);
}
}, [series?.id, onSeriesSelect]);
if (series == null) {
return null;
}
const series = items[index];
return (
<VirtualTableRowButton
style={{
@@ -84,7 +85,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
justifyContent: 'space-between',
...style,
}}
onPress={() => onSeriesSelect(series.id)}
onPress={handlePress}
>
<SelectSeriesRow
key={series.id}
@@ -98,7 +99,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
/>
</VirtualTableRowButton>
);
};
}
function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
const { modalTitle, onSeriesSelect, onModalClose } = props;
@@ -197,9 +198,9 @@ function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
/>
<Scroller
ref={scrollerRef}
className={styles.scroller}
autoFocus={false}
ref={scrollerRef}
>
<SelectSeriesModalTableHeader columns={columns} />
<List<RowItemData>

View File

@@ -86,9 +86,10 @@ class InteractiveSearchConnector extends Component {
}
InteractiveSearchConnector.propTypes = {
type: PropTypes.string.isRequired,
searchPayload: PropTypes.object.isRequired,
isPopulated: PropTypes.bool.isRequired,
dispatchFetchReleases: PropTypes.func.isRequired
isPopulated: PropTypes.bool,
dispatchFetchReleases: PropTypes.func
};
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector);

View File

@@ -264,7 +264,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
<TableRowCell className={styles.indexerFlags}>
{indexerFlags ? (
<Popover
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
anchor={<Icon name={icons.FLAG} />}
title={translate('IndexerFlags')}
body={<IndexerFlags indexerFlags={indexerFlags} />}
position={tooltipPositions.LEFT}

View File

@@ -17,7 +17,7 @@ function SelectDownloadClientModal(props: SelectDownloadClientModalProps) {
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
<SelectDownloadClientModalContent
protocol={protocol}
modalTitle={modalTitle}

View File

@@ -1,4 +1,4 @@
import React, { Fragment, useCallback, useState } from 'react';
import React, { useCallback, useState } from 'react';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
import ParseModal from 'Parse/ParseModal';
@@ -16,7 +16,7 @@ function ParseToolbarButton() {
}, [setIsParseModalOpen]);
return (
<Fragment>
<>
<PageToolbarButton
label={translate('TestParsing')}
iconName={icons.PARSE}
@@ -24,7 +24,7 @@ function ParseToolbarButton() {
/>
<ParseModal isOpen={isParseModalOpen} onModalClose={onParseModalClose} />
</Fragment>
</>
);
}

View File

@@ -21,7 +21,7 @@ interface RootFolderRowProps {
}
function RootFolderRow(props: RootFolderRowProps) {
const { id, path, accessible, freeSpace, unmappedFolders = [] } = props;
const { id, path, accessible, freeSpace = 0, unmappedFolders = [] } = props;
const isUnavailable = !accessible;

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