1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-18 21:35:27 -04:00

Compare commits

...

48 Commits

Author SHA1 Message Date
Mark McDowall 4ac61fc4c6 Remove v3 updates from UI 2025-10-06 06:48:38 +09:00
Mark McDowall 46c9b98fad Use react-query for Log Files 2025-10-06 06:41:42 +09:00
Mark McDowall 7ade5b1259 Add v5 log files endpoints 2025-10-04 21:04:12 -07:00
Mark McDowall a4f210855e Use react-query for Blocklist UI 2025-10-01 19:21:50 -07:00
Mark McDowall bc4ad574fc Add v5 Blocklist endpoints 2025-10-01 19:21:50 -07:00
Mark McDowall a5ea19ddfb New: Optional message for marking as failed via API
Closes #7775
2025-10-01 19:21:50 -07:00
Bogdan 858c690543 Remove redundant code in selecting with click on poster 2025-10-01 19:21:43 -07:00
Mark McDowall 8e169561f2 Adjust series details title line height to account for descenders 2025-10-01 19:21:36 -07:00
Mark McDowall 994faa60c6 New: Don't reparse OVA with folder name
Closes #8083
2025-10-01 19:21:26 -07:00
Mark McDowall a4a18d6121 New: Support PEM format for SSL certificates
Closes #8087
2025-10-01 19:21:04 -07:00
Weblate 0407564784 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ArLab1 <arnaud.laberge@hotmail.com>
Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translation: Servarr/Sonarr
2025-10-01 19:20:55 -07:00
Bogdan 0a61e66ef1 Avoid rewriting file names in builds 2025-09-27 16:01:40 -07:00
Bogdan 051451eb2a Bump coverlet.collector to official 6.0.4 2025-09-27 16:01:40 -07:00
Mark McDowall a4e3be721d New: Parse 'Por' as Portuguese
Closes #8057
2025-09-27 15:53:01 -07:00
Mark McDowall 224e74605b Fixed: Incorrectly parsing English subtitles as English audio 2025-09-27 15:53:01 -07:00
Collin Heist 6c581b7e3c Fixed: Prevent modals from overflowing screen width
Closes #8085
2025-09-27 15:50:27 -07:00
Bogdan 6588ba8435 Bump actions/checkout, actions/download-artifact and actions/setup-dotnet 2025-09-27 15:50:00 -07:00
Bogdan ce4c2e4fcc Test versions 16 and 17 of Postgres 2025-09-27 15:50:00 -07:00
Bogdan b1f77007dc Switch HttpProxySettingsProviderFixture to test cases 2025-09-27 15:48:39 -07:00
Bogdan 8bab0a06dd Attempt to remove pid file only if config folder exists 2025-09-27 15:48:28 -07:00
Bogdan 5b135addaa Fix legacy app data folder path on OSX 2025-09-27 15:48:28 -07:00
Stevie Robinson 4904e85887 New: Switch theme automatically on system change
Closes #8068
2025-09-27 15:48:06 -07:00
Bogdan c4978022eb Fix clearing pending changes for First Run
`TypeError: can't access property "section", a is undefined`
2025-09-27 15:47:28 -07:00
Brian Wentzloff 15e9350601 Add missing word to 'AddNewSeriesHelpText' English translation 2025-09-27 15:47:21 -07:00
Bogdan 2e1289b924 New: Retry SQLite writes for database is locked errors 2025-09-27 15:46:45 -07:00
Bogdan 7dac00d5aa Bump System.Data.SQLite to official 2.0.2
Bump sqlite3 to 3.50.4
2025-09-27 15:46:37 -07:00
Bogdan 3796c9e30f Bump FluentMigrator to official 6.2.0 2025-09-27 15:46:32 -07:00
Mark McDowall f2f4edad0c New: Parse Chinese season packs and multi-episode releases
Closes #8042
2025-09-27 15:41:59 -07:00
Sonarr b0b15c78ff Automated API Docs update
ignore-downstream
2025-09-27 15:41:51 -07:00
Mark McDowall 64c421c187 New: Subtitles indexer flag to indicate releases with subtitles
Closes #7625
2025-09-27 15:41:45 -07:00
Tro95 6440151053 New: Excluded Tags on Release Profile 2025-09-27 15:41:33 -07:00
sparky3387 cf6b21aef6 New: Setting to allow for grabbing season packs even if some episodes already meet cutoff
Closes #6378
2025-09-27 15:40:07 -07:00
Stevie Robinson 1610e54650 correct migration number and fix for postgres 2025-09-01 15:08:37 -07:00
Mark McDowall c40fbeed50 New: Parse Chinese and English titles as separate titles
Closes #8035
2025-09-01 15:08:31 -07:00
Mark McDowall b57e7e2db0 New: Parse releases using Temporada as the season
Closes #8030
2025-09-01 15:08:31 -07:00
Mark McDowall 478866b2bb New: Changing icon during import to blue
Closes #7992
2025-09-01 15:08:24 -07:00
Mark McDowall ae201f5299 Use react-query for queue UI
New: Season packs and multi-episode releases will show as a single item in the queue
Closes #6537
2025-09-01 15:08:19 -07:00
Mark McDowall 642f4f97bc Add v5 queue endpoints 2025-09-01 15:08:19 -07:00
Alexander WB 37cb978f18 New: RQBit download client
Co-authored-by: Mark Mendoza <markolo25@gmail.com>
2025-09-01 15:08:03 -07:00
康小广 7fdc4d6638 Follow redirects when fetching Custom Lists 2025-09-01 14:59:34 -07:00
grapexy 309b55fe38 New: Georgian language 2025-09-01 14:58:40 -07:00
oxfordllama d6f265c7b5 Subtitles indexer flag to indicate BTN releases with subtitles 2025-09-01 14:58:19 -07:00
Weblate e757dca038 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ArLab1 <arnaud.laberge@hotmail.com>
Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: NanderTGA <nander.roobaert@gmail.com>
Co-authored-by: ReDFiRe <wwsoft@abv.bg>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Xoores <servarr-35466@xoores.cz>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: mrchonks <chonkstv@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/cs/
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/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-09-01 14:56:39 -07:00
Mark McDowall 9ebe043bd9 New: Move auth success logging to debug
Closes #7978
2025-08-10 21:26:53 -07:00
Mark McDowall f055e8a3e5 Fixed: Parsing English as the second language in a release name
Closes #8006
2025-08-10 21:26:39 -07:00
Sonarr 8c697afa67 Automated API Docs update
ignore-downstream
2025-08-10 21:20:03 -07:00
Weblate 8d68879edd Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ArLab1 <arnaud.laberge@hotmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translation: Servarr/Sonarr
2025-08-10 21:19:54 -07:00
Trey Turner e9c82078da Fixed: File air date being updated every refresh
Closes #7989
2025-08-10 21:18:34 -07:00
239 changed files with 8126 additions and 2817 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ runs:
using: "composite" using: "composite"
steps: steps:
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v5
- name: Setup Environment Variables - name: Setup Environment Variables
id: variables id: variables
+9 -5
View File
@@ -4,6 +4,8 @@ description: Runs unit/integration tests
inputs: inputs:
use_postgres: use_postgres:
description: 'Whether postgres should be used for the database' description: 'Whether postgres should be used for the database'
postgres-version:
description: 'Which postgres version should be used for the database'
os: os:
description: 'OS that the tests are running on' description: 'OS that the tests are running on'
required: true required: true
@@ -27,16 +29,18 @@ runs:
using: 'composite' using: 'composite'
steps: steps:
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v5
- name: Setup Postgres - name: Setup Postgres
if: ${{ inputs.use_postgres }} if: ${{ inputs.use_postgres }}
uses: ikalnytskyi/action-setup-postgres@v4 uses: ikalnytskyi/action-setup-postgres@v7
with:
postgres-version: ${{ inputs.postgres-version }}
- name: Setup Test Variables - name: Setup Test Variables
shell: bash shell: bash
run: | run: |
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}" >> "$GITHUB_ENV" echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}${{ inputs.use_postgres && inputs.postgres-version && inputs.postgres-version }}" >> "$GITHUB_ENV"
- name: Setup Postgres Environment Variables - name: Setup Postgres Environment Variables
if: ${{ inputs.use_postgres }} if: ${{ inputs.use_postgres }}
@@ -48,14 +52,14 @@ runs:
echo "Sonarr__Postgres__Password=postgres" >> "$GITHUB_ENV" echo "Sonarr__Postgres__Password=postgres" >> "$GITHUB_ENV"
- name: Download Artifact - name: Download Artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
name: ${{ inputs.artifact }} name: ${{ inputs.artifact }}
path: _tests path: _tests
- name: Download Binary Artifact - name: Download Binary Artifact
if: ${{ inputs.integration_tests }} if: ${{ inputs.integration_tests }}
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
name: ${{ inputs.binary_artifact }} name: ${{ inputs.binary_artifact }}
path: _output path: _output
+10 -5
View File
@@ -82,7 +82,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Build - name: Build
uses: ./.github/actions/build uses: ./.github/actions/build
@@ -97,7 +97,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Volta - name: Volta
uses: volta-cli/action@v4 uses: volta-cli/action@v4
@@ -139,7 +139,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Test - name: Test
uses: ./.github/actions/test uses: ./.github/actions/test
@@ -152,9 +152,13 @@ jobs:
unit_test_postgres: unit_test_postgres:
needs: backend needs: backend
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
postgres-version: [16, 17]
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Test - name: Test
uses: ./.github/actions/test uses: ./.github/actions/test
@@ -164,6 +168,7 @@ jobs:
pattern: Sonarr.*.Test.dll pattern: Sonarr.*.Test.dll
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
use_postgres: true use_postgres: true
postgres-version: ${{ matrix.postgres-version }}
integration_test: integration_test:
needs: [prepare, backend] needs: [prepare, backend]
@@ -190,7 +195,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Test - name: Test
uses: ./.github/actions/test uses: ./.github/actions/test
+60 -95
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { setQueueOptions } from 'Activity/Queue/queueOptionsStore';
import { SelectProvider } from 'App/SelectContext'; import { SelectProvider } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -16,20 +16,8 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager'; import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useSelectState from 'Helpers/Hooks/useSelectState'; import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import {
clearBlocklist,
fetchBlocklist,
gotoBlocklistPage,
removeBlocklistItems,
setBlocklistFilter,
setBlocklistSort,
setBlocklistTableOption,
} from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
@@ -43,27 +31,35 @@ import {
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds'; import getSelectedIds from 'Utilities/Table/getSelectedIds';
import BlocklistFilterModal from './BlocklistFilterModal'; import BlocklistFilterModal from './BlocklistFilterModal';
import {
setBlocklistOption,
useBlocklistOptions,
} from './blocklistOptionsStore';
import BlocklistRow from './BlocklistRow'; import BlocklistRow from './BlocklistRow';
import useBlocklist, {
useFilters,
useRemoveBlocklistItems,
} from './useBlocklist';
function Blocklist() { function Blocklist() {
const requestCurrentPage = useCurrentPage();
const { const {
isFetching, records,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages, totalPages,
totalRecords, totalRecords,
isRemoving, isFetching,
} = useSelector((state: AppState) => state.blocklist); isFetched,
isLoading,
error,
page,
goToPage,
refetch,
} = useBlocklist();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useBlocklistOptions();
const filters = useFilters();
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
const customFilters = useSelector(createCustomFiltersSelector('blocklist')); const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
const isClearingBlocklistExecuting = useSelector( const isClearingBlocklistExecuting = useSelector(
@@ -82,28 +78,27 @@ function Blocklist() {
return getSelectedIds(selectedState); return getSelectedIds(selectedState);
}, [selectedState]); }, [selectedState]);
const wasClearingBlocklistExecuting = usePrevious(
isClearingBlocklistExecuting
);
const handleSelectAllChange = useCallback( const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => { ({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); setSelectState({
type: value ? 'selectAll' : 'unselectAll',
items: records,
});
}, },
[items, setSelectState] [records, setSelectState]
); );
const handleSelectedChange = useCallback( const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => { ({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({ setSelectState({
type: 'toggleSelected', type: 'toggleSelected',
items, items: records,
id, id,
isSelected: value, isSelected: value,
shiftKey, shiftKey,
}); });
}, },
[items, setSelectState] [records, setSelectState]
); );
const handleRemoveSelectedPress = useCallback(() => { const handleRemoveSelectedPress = useCallback(() => {
@@ -111,9 +106,9 @@ function Blocklist() {
}, [setIsConfirmRemoveModalOpen]); }, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback(() => { const handleRemoveSelectedConfirmed = useCallback(() => {
dispatch(removeBlocklistItems({ ids: selectedIds })); removeBlocklistItems({ ids: selectedIds });
setIsConfirmRemoveModalOpen(false); setIsConfirmRemoveModalOpen(false);
}, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]); }, [selectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]);
const handleConfirmRemoveModalClose = useCallback(() => { const handleConfirmRemoveModalClose = useCallback(() => {
setIsConfirmRemoveModalOpen(false); setIsConfirmRemoveModalOpen(false);
@@ -124,66 +119,46 @@ function Blocklist() {
}, [setIsConfirmClearModalOpen]); }, [setIsConfirmClearModalOpen]);
const handleClearBlocklistConfirmed = useCallback(() => { const handleClearBlocklistConfirmed = useCallback(() => {
dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST })); dispatch(
executeCommand({
name: commandNames.CLEAR_BLOCKLIST,
commandFinished: () => {
goToPage(1);
},
})
);
setIsConfirmClearModalOpen(false); setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen, dispatch]); }, [setIsConfirmClearModalOpen, goToPage, dispatch]);
const handleConfirmClearModalClose = useCallback(() => { const handleConfirmClearModalClose = useCallback(() => {
setIsConfirmClearModalOpen(false); setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen]); }, [setIsConfirmClearModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoBlocklistPage,
});
const handleFilterSelect = useCallback( const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => { (selectedFilterKey: string | number) => {
dispatch(setBlocklistFilter({ selectedFilterKey })); setBlocklistOption('selectedFilterKey', selectedFilterKey);
}, },
[dispatch] []
); );
const handleSortPress = useCallback( const handleSortPress = useCallback((sortKey: string) => {
(sortKey: string) => { setBlocklistOption('sortKey', sortKey);
dispatch(setBlocklistSort({ sortKey })); }, []);
},
[dispatch]
);
const handleTableOptionChange = useCallback( const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => { (payload: TableOptionsChangePayload) => {
dispatch(setBlocklistTableOption(payload)); setQueueOptions(payload);
if (payload.pageSize) { if (payload.pageSize) {
dispatch(gotoBlocklistPage({ page: 1 })); goToPage(1);
} }
}, },
[dispatch] [goToPage]
); );
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchBlocklist());
} else {
dispatch(gotoBlocklistPage({ page: 1 }));
}
return () => {
dispatch(clearBlocklist());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
dispatch(fetchBlocklist()); refetch();
}; };
registerPagePopulator(repopulate); registerPagePopulator(repopulate);
@@ -191,16 +166,10 @@ function Blocklist() {
return () => { return () => {
unregisterPagePopulator(repopulate); unregisterPagePopulator(repopulate);
}; };
}, [dispatch]); }, [refetch]);
useEffect(() => {
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
dispatch(gotoBlocklistPage({ page: 1 }));
}
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
return ( return (
<SelectProvider items={items}> <SelectProvider items={records}>
<PageContent title={translate('Blocklist')}> <PageContent title={translate('Blocklist')}>
<PageToolbar> <PageToolbar>
<PageToolbarSection> <PageToolbarSection>
@@ -215,7 +184,7 @@ function Blocklist() {
<PageToolbarButton <PageToolbarButton
label={translate('Clear')} label={translate('Clear')}
iconName={icons.CLEAR} iconName={icons.CLEAR}
isDisabled={!items.length} isDisabled={!records.length}
isSpinning={isClearingBlocklistExecuting} isSpinning={isClearingBlocklistExecuting}
onPress={handleClearBlocklistPress} onPress={handleClearBlocklistPress}
/> />
@@ -245,13 +214,13 @@ function Blocklist() {
</PageToolbar> </PageToolbar>
<PageContentBody> <PageContentBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null} {isLoading && !isFetched ? <LoadingIndicator /> : null}
{!isFetching && !!error ? ( {!isLoading && !!error ? (
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert> <Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
) : null} ) : null}
{isPopulated && !error && !items.length ? ( {isFetched && !error && !records.length ? (
<Alert kind={kinds.INFO}> <Alert kind={kinds.INFO}>
{selectedFilterKey === 'all' {selectedFilterKey === 'all'
? translate('NoBlocklistItems') ? translate('NoBlocklistItems')
@@ -259,7 +228,7 @@ function Blocklist() {
</Alert> </Alert>
) : null} ) : null}
{isPopulated && !error && !!items.length ? ( {isFetched && !error && !!records.length ? (
<div> <div>
<Table <Table
selectAll={true} selectAll={true}
@@ -274,7 +243,7 @@ function Blocklist() {
onSortPress={handleSortPress} onSortPress={handleSortPress}
> >
<TableBody> <TableBody>
{items.map((item) => { {records.map((item) => {
return ( return (
<BlocklistRow <BlocklistRow
key={item.id} key={item.id}
@@ -292,11 +261,7 @@ function Blocklist() {
totalPages={totalPages} totalPages={totalPages}
totalRecords={totalRecords} totalRecords={totalRecords}
isFetching={isFetching} isFetching={isFetching}
onFirstPagePress={handleFirstPagePress} onPageSelect={goToPage}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/> />
</div> </div>
) : null} ) : null}
@@ -16,13 +16,19 @@ interface BlocklistDetailsModalProps {
protocol: DownloadProtocol; protocol: DownloadProtocol;
indexer?: string; indexer?: string;
message?: string; message?: string;
source?: string;
onModalClose: () => void; onModalClose: () => void;
} }
function BlocklistDetailsModal(props: BlocklistDetailsModalProps) { function BlocklistDetailsModal({
const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } = isOpen,
props; sourceTitle,
protocol,
indexer,
message,
source,
onModalClose,
}: BlocklistDetailsModalProps) {
return ( return (
<Modal isOpen={isOpen} onModalClose={onModalClose}> <Modal isOpen={isOpen} onModalClose={onModalClose}>
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
@@ -50,6 +56,9 @@ function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
data={message} data={message}
/> />
) : null} ) : null}
{source ? (
<DescriptionListItem title={translate('Source')} data={source} />
) : null}
</DescriptionList> </DescriptionList>
</ModalBody> </ModalBody>
@@ -1,50 +1,26 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal'; import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setBlocklistFilter } from 'Store/Actions/blocklistActions'; import { setBlocklistOption } from './blocklistOptionsStore';
import useBlocklist, { FILTER_BUILDER } from './useBlocklist';
function createBlocklistSelector() {
return createSelector(
(state: AppState) => state.blocklist.items,
(blocklistItems) => {
return blocklistItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.blocklist.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
type BlocklistFilterModalProps = FilterModalProps<History>; type BlocklistFilterModalProps = FilterModalProps<History>;
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) { export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
const sectionItems = useSelector(createBlocklistSelector()); const { records } = useBlocklist();
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'blocklist';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback( const dispatchSetFilter = useCallback(
(payload: unknown) => { ({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
dispatch(setBlocklistFilter(payload)); setBlocklistOption('selectedFilterKey', selectedFilterKey);
}, },
[dispatch] []
); );
return ( return (
<FilterModal <FilterModal
{...props} {...props}
sectionItems={sectionItems} sectionItems={records}
filterBuilderProps={filterBuilderProps} filterBuilderProps={FILTER_BUILDER}
customFilterType={customFilterType} customFilterType="blocklist"
dispatchSetFilter={dispatchSetFilter} dispatchSetFilter={dispatchSetFilter}
/> />
); );
@@ -1,5 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -12,11 +11,11 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import SeriesTitleLink from 'Series/SeriesTitleLink'; import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { removeBlocklistItem } from 'Store/Actions/blocklistActions';
import Blocklist from 'typings/Blocklist'; import Blocklist from 'typings/Blocklist';
import { SelectStateInputProps } from 'typings/props'; import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import BlocklistDetailsModal from './BlocklistDetailsModal'; import BlocklistDetailsModal from './BlocklistDetailsModal';
import { useRemoveBlocklistItem } from './useBlocklist';
import styles from './BlocklistRow.css'; import styles from './BlocklistRow.css';
interface BlocklistRowProps extends Blocklist { interface BlocklistRowProps extends Blocklist {
@@ -25,25 +24,24 @@ interface BlocklistRowProps extends Blocklist {
onSelectedChange: (options: SelectStateInputProps) => void; onSelectedChange: (options: SelectStateInputProps) => void;
} }
function BlocklistRow(props: BlocklistRowProps) { function BlocklistRow({
const { id,
id, seriesId,
seriesId, sourceTitle,
sourceTitle, languages,
languages, quality,
quality, customFormats,
customFormats, date,
date, protocol,
protocol, indexer,
indexer, message,
message, source,
isSelected, isSelected,
columns, columns,
onSelectedChange, onSelectedChange,
} = props; }: BlocklistRowProps) {
const series = useSeries(seriesId); const series = useSeries(seriesId);
const dispatch = useDispatch(); const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const handleDetailsPress = useCallback(() => { const handleDetailsPress = useCallback(() => {
@@ -55,8 +53,8 @@ function BlocklistRow(props: BlocklistRowProps) {
}, [setIsDetailsModalOpen]); }, [setIsDetailsModalOpen]);
const handleRemovePress = useCallback(() => { const handleRemovePress = useCallback(() => {
dispatch(removeBlocklistItem({ id })); removeBlocklistItem();
}, [id, dispatch]); }, [removeBlocklistItem]);
if (!series) { if (!series) {
return null; return null;
@@ -139,6 +137,7 @@ function BlocklistRow(props: BlocklistRowProps) {
title={translate('RemoveFromBlocklist')} title={translate('RemoveFromBlocklist')}
name={icons.REMOVE} name={icons.REMOVE}
kind={kinds.DANGER} kind={kinds.DANGER}
isSpinning={isRemoving}
onPress={handleRemovePress} onPress={handleRemovePress}
/> />
</TableRowCell> </TableRowCell>
@@ -154,6 +153,7 @@ function BlocklistRow(props: BlocklistRowProps) {
protocol={protocol} protocol={protocol}
indexer={indexer} indexer={indexer}
message={message} message={message}
source={source}
onModalClose={handleDetailsModalClose} onModalClose={handleDetailsModalClose}
/> />
</TableRow> </TableRow>
@@ -0,0 +1,71 @@
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import translate from 'Utilities/String/translate';
export type BlocklistOptions = PageableOptions;
const { useOptions, useOption, setOptions, setOption } =
createOptionsStore<BlocklistOptions>('blocklist_options', () => {
return {
pageSize: 20,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'series.sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true,
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isSortable: true,
isVisible: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false,
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true,
},
{
name: 'date',
label: () => translate('Date'),
isSortable: true,
isVisible: true,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useBlocklistOptions = useOptions;
export const setBlocklistOptions = setOptions;
export const useBlocklistOption = useOption;
export const setBlocklistOption = setOption;
@@ -0,0 +1,116 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import Blocklist from 'typings/Blocklist';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { useBlocklistOptions } from './blocklistOptionsStore';
interface BulkBlocklistData {
ids: number[];
}
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
];
export const FILTER_BUILDER: FilterBuilderProp<Blocklist>[] = [
{
name: 'seriesIds',
label: () => translate('Series'),
type: 'equal',
valueType: filterBuilderValueTypes.SERIES,
},
{
name: 'protocols',
label: () => translate('Protocol'),
type: 'equal',
valueType: filterBuilderValueTypes.PROTOCOL,
},
];
const useBlocklist = () => {
const { page, goToPage } = usePage('blocklist');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useBlocklistOptions();
const customFilters = useSelector(
createCustomFiltersSelector('blocklist')
) as CustomFilter[];
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<Blocklist>({
path: '/blocklist',
page,
pageSize,
filters,
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
return {
...query,
goToPage,
page,
refetch,
};
};
export default useBlocklist;
export const useFilters = () => {
return FILTERS;
};
export const useRemoveBlocklistItem = (id: number) => {
const queryClient = useQueryClient();
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/blocklist/${id}`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
},
},
});
return {
removeBlocklistItem: mutate,
isRemoving: isPending,
};
};
export const useRemoveBlocklistItems = () => {
const queryClient = useQueryClient();
const { mutate, isPending } = useApiMutation<unknown, BulkBlocklistData>({
path: `/blocklist/bulk`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
},
},
});
return {
removeBlocklistItems: mutate,
isRemoving: isPending,
};
};
@@ -174,7 +174,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
} }
if (eventType === 'downloadFailed') { if (eventType === 'downloadFailed') {
const { message, indexer } = data as DownloadFailedHistory; const { indexer, message, source } = data as DownloadFailedHistory;
return ( return (
<DescriptionList> <DescriptionList>
@@ -195,6 +195,10 @@ function HistoryDetails(props: HistoryDetailsProps) {
{message ? ( {message ? (
<DescriptionListItem title={translate('Message')} data={message} /> <DescriptionListItem title={translate('Message')} data={message} />
) : null} ) : null}
{source ? (
<DescriptionListItem title={translate('Source')} data={source} />
) : null}
</DescriptionList> </DescriptionList>
); );
} }
@@ -0,0 +1,113 @@
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Queue from 'typings/Queue';
interface EpisodeDetails {
episodeIds: number[];
}
interface SeriesDetails {
seriesId: number;
}
interface AllDetails {
all: boolean;
}
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
interface QueueDetailsProps {
children: ReactNode;
}
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
export default function QueueDetailsProvider({
children,
...filter
}: QueueDetailsProps & QueueDetailsFilter) {
const { data } = useApiQuery<Queue[]>({
path: '/queue/details',
queryParams: { ...filter },
queryOptions: {
enabled: Object.keys(filter).length > 0,
},
});
return (
<QueueDetailsContext.Provider value={data}>
{children}
</QueueDetailsContext.Provider>
);
}
export function useQueueItemForEpisode(episodeId: number) {
const queue = useContext(QueueDetailsContext);
return useMemo(() => {
return queue?.find((item) => item.episodeIds.includes(episodeId));
}, [episodeId, queue]);
}
export function useIsDownloadingEpisodes(episodeIds: number[]) {
const queue = useContext(QueueDetailsContext);
return useMemo(() => {
if (!queue) {
return false;
}
return queue.some((item) =>
item.episodeIds?.some((e) => episodeIds.includes(e))
);
}, [episodeIds, queue]);
}
export interface SeriesQueueDetails {
count: number;
episodesWithFiles: number;
}
export function useQueueDetailsForSeries(
seriesId: number,
seasonNumber?: number
) {
const queue = useContext(QueueDetailsContext);
return useMemo<SeriesQueueDetails>(() => {
if (!queue) {
return { count: 0, episodesWithFiles: 0 };
}
return queue.reduce<SeriesQueueDetails>(
(acc: SeriesQueueDetails, item) => {
if (
item.trackedDownloadState === 'imported' ||
item.seriesId !== seriesId
) {
return acc;
}
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
return acc;
}
acc.count++;
if (item.episodeHasFile) {
acc.episodesWithFiles++;
}
return acc;
},
{
count: 0,
episodesWithFiles: 0,
}
);
}, [seriesId, seasonNumber, queue]);
}
export const useQueueDetails = () => {
return useContext(QueueDetailsContext) ?? [];
};
@@ -0,0 +1,76 @@
import React from 'react';
import Episode from 'Episode/Episode';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import Series from 'Series/Series';
import translate from 'Utilities/String/translate';
interface EpisodeCellContentProps {
episodes: Episode[];
isFullSeason: boolean;
seasonNumber?: number;
series?: Series;
}
export default function EpisodeCellContent({
episodes,
isFullSeason,
seasonNumber,
series,
}: EpisodeCellContentProps) {
if (episodes.length === 0) {
return '-';
}
if (isFullSeason && seasonNumber != null) {
return translate('SeasonNumberToken', { seasonNumber });
}
if (episodes.length === 1) {
const episode = episodes[0];
return (
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/>
);
}
const firstEpisode = episodes[0];
const lastEpisode = episodes[episodes.length - 1];
return (
<>
<SeasonEpisodeNumber
seasonNumber={firstEpisode.seasonNumber}
episodeNumber={firstEpisode.episodeNumber}
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
/>
{' - '}
<SeasonEpisodeNumber
seasonNumber={lastEpisode.seasonNumber}
episodeNumber={lastEpisode.episodeNumber}
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
/>
</>
);
}
@@ -0,0 +1,13 @@
.multiple {
cursor: default;
}
.row {
display: flex;
}
.episodeNumber {
margin-right: 8px;
font-weight: bold;
cursor: default;
}
@@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'episodeNumber': string;
'multiple': string;
'row': string;
}
export const cssExports: CssExports;
export default cssExports;
@@ -0,0 +1,66 @@
import React from 'react';
import Popover from 'Components/Tooltip/Popover';
import Episode from 'Episode/Episode';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import Series from 'Series/Series';
import translate from 'Utilities/String/translate';
import styles from './EpisodeTitleCellContent.css';
interface EpisodeTitleCellContentProps {
episodes: Episode[];
series?: Series;
}
export default function EpisodeTitleCellContent({
episodes,
series,
}: EpisodeTitleCellContentProps) {
if (episodes.length === 0 || !series) {
return '-';
}
if (episodes.length === 1) {
const episode = episodes[0];
return (
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
);
}
return (
<Popover
anchor={
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
}
title={translate('EpisodeTitles')}
body={
<>
{episodes.map((episode) => {
return (
<div key={episode.id} className={styles.row}>
<div className={styles.episodeNumber}>
{episode.episodeNumber}
</div>
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
</div>
);
})}
</>
}
position="right"
/>
);
}
+70 -105
View File
@@ -7,7 +7,6 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -22,28 +21,15 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager'; import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector'; import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import useSelectState from 'Helpers/Hooks/useSelectState'; import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import {
clearQueue,
fetchQueue,
gotoQueuePage,
grabQueueItems,
removeQueueItems,
setQueueFilter,
setQueueSort,
setQueueTableOption,
} from 'Store/Actions/queueActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs'; import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props'; import { SelectStateInputProps } from 'typings/props';
import QueueItem from 'typings/Queue';
import { TableOptionsChangePayload } from 'typings/Table'; import { TableOptionsChangePayload } from 'typings/Table';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { import {
@@ -54,33 +40,45 @@ import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds'; import getSelectedIds from 'Utilities/Table/getSelectedIds';
import QueueFilterModal from './QueueFilterModal'; import QueueFilterModal from './QueueFilterModal';
import QueueOptions from './QueueOptions'; import QueueOptions from './QueueOptions';
import {
setQueueOption,
setQueueOptions,
useQueueOptions,
} from './queueOptionsStore';
import QueueRow from './QueueRow'; import QueueRow from './QueueRow';
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal'; import RemoveQueueItemModal from './RemoveQueueItemModal';
import createQueueStatusSelector from './Status/createQueueStatusSelector'; import useQueueStatus from './Status/useQueueStatus';
import useQueue, {
useFilters,
useGrabQueueItems,
useRemoveQueueItems,
} from './useQueue';
function Queue() { function Queue() {
const requestCurrentPage = useCurrentPage();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { const {
isFetching, records,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages, totalPages,
totalRecords, totalRecords,
isGrabbing, error,
isRemoving, isFetching,
} = useSelector((state: AppState) => state.queue.paged); isFetched,
isLoading,
page,
goToPage,
refetch,
} = useQueue();
const { count } = useSelector(createQueueStatusSelector()); const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useQueueOptions();
const filters = useFilters();
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
const { count } = useQueueStatus();
const { isEpisodesFetching, isEpisodesPopulated, episodesError } = const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector()); useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('queue')); const customFilters = useSelector(createCustomFiltersSelector('queue'));
@@ -100,41 +98,46 @@ function Queue() {
}, [selectedState]); }, [selectedState]);
const isPendingSelected = useMemo(() => { const isPendingSelected = useMemo(() => {
return items.some((item) => { return records.some((item) => {
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
}); });
}, [items, selectedIds]); }, [records, selectedIds]);
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
useState(false); useState(false);
const isRefreshing = const isRefreshing =
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
const isAllPopulated = const isAllPopulated =
isPopulated && isFetched &&
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId)); (isEpisodesPopulated ||
!records.length ||
records.every((e) => !e.episodeIds?.length));
const hasError = error || episodesError; const hasError = error || episodesError;
const selectedCount = selectedIds.length; const selectedCount = selectedIds.length;
const disableSelectedActions = selectedCount === 0; const disableSelectedActions = selectedCount === 0;
const handleSelectAllChange = useCallback( const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => { ({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); setSelectState({
type: value ? 'selectAll' : 'unselectAll',
items: records,
});
}, },
[items, setSelectState] [records, setSelectState]
); );
const handleSelectedChange = useCallback( const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => { ({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({ setSelectState({
type: 'toggleSelected', type: 'toggleSelected',
items, items: records,
id, id,
isSelected: value, isSelected: value,
shiftKey, shiftKey,
}); });
}, },
[items, setSelectState] [records, setSelectState]
); );
const handleRefreshPress = useCallback(() => { const handleRefreshPress = useCallback(() => {
@@ -150,93 +153,60 @@ function Queue() {
}, []); }, []);
const handleGrabSelectedPress = useCallback(() => { const handleGrabSelectedPress = useCallback(() => {
dispatch(grabQueueItems({ ids: selectedIds })); grabQueueItems({ ids: selectedIds });
}, [selectedIds, dispatch]); }, [selectedIds, grabQueueItems]);
const handleRemoveSelectedPress = useCallback(() => { const handleRemoveSelectedPress = useCallback(() => {
shouldBlockRefresh.current = true; shouldBlockRefresh.current = true;
setIsConfirmRemoveModalOpen(true); setIsConfirmRemoveModalOpen(true);
}, [setIsConfirmRemoveModalOpen]); }, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback( const handleRemoveSelectedConfirmed = useCallback(() => {
(payload: RemovePressProps) => { shouldBlockRefresh.current = false;
shouldBlockRefresh.current = false; removeQueueItems({ ids: selectedIds });
dispatch(removeQueueItems({ ids: selectedIds, ...payload })); setIsConfirmRemoveModalOpen(false);
setIsConfirmRemoveModalOpen(false); }, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]);
},
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
);
const handleConfirmRemoveModalClose = useCallback(() => { const handleConfirmRemoveModalClose = useCallback(() => {
shouldBlockRefresh.current = false; shouldBlockRefresh.current = false;
setIsConfirmRemoveModalOpen(false); setIsConfirmRemoveModalOpen(false);
}, [setIsConfirmRemoveModalOpen]); }, [setIsConfirmRemoveModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoQueuePage,
});
const handleFilterSelect = useCallback( const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => { (selectedFilterKey: string | number) => {
dispatch(setQueueFilter({ selectedFilterKey })); setQueueOption('selectedFilterKey', selectedFilterKey);
}, },
[dispatch] []
); );
const handleSortPress = useCallback( const handleSortPress = useCallback((sortKey: string) => {
(sortKey: string) => { setQueueOption('sortKey', sortKey);
dispatch(setQueueSort({ sortKey })); }, []);
},
[dispatch]
);
const handleTableOptionChange = useCallback( const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => { (payload: TableOptionsChangePayload) => {
dispatch(setQueueTableOption(payload)); setQueueOptions(payload);
if (payload.pageSize) { if (payload.pageSize) {
dispatch(gotoQueuePage({ page: 1 })); goToPage(1);
} }
}, },
[dispatch] [goToPage]
); );
useEffect(() => { useEffect(() => {
if (requestCurrentPage) { const episodeIds = selectUniqueIds(records, 'episodeIds');
dispatch(fetchQueue());
} else {
dispatch(gotoQueuePage({ page: 1 }));
}
return () => {
dispatch(clearQueue());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
items,
'episodeId'
);
if (episodeIds.length) { if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds })); dispatch(fetchEpisodes({ episodeIds }));
} else { } else {
dispatch(clearEpisodes()); dispatch(clearEpisodes());
} }
}, [items, dispatch]); }, [records, dispatch]);
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
dispatch(fetchQueue()); refetch();
}; };
registerPagePopulator(repopulate); registerPagePopulator(repopulate);
@@ -244,7 +214,7 @@ function Queue() {
return () => { return () => {
unregisterPagePopulator(repopulate); unregisterPagePopulator(repopulate);
}; };
}, [dispatch]); }, [refetch]);
if (!shouldBlockRefresh.current) { if (!shouldBlockRefresh.current) {
currentQueue.current = ( currentQueue.current = (
@@ -255,7 +225,7 @@ function Queue() {
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert> <Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
) : null} ) : null}
{isAllPopulated && !hasError && !items.length ? ( {isAllPopulated && !hasError && !records.length ? (
<Alert kind={kinds.INFO}> <Alert kind={kinds.INFO}>
{selectedFilterKey !== 'all' && count > 0 {selectedFilterKey !== 'all' && count > 0
? translate('QueueFilterHasNoItems') ? translate('QueueFilterHasNoItems')
@@ -263,7 +233,7 @@ function Queue() {
</Alert> </Alert>
) : null} ) : null}
{isAllPopulated && !hasError && !!items.length ? ( {isAllPopulated && !hasError && !!records.length ? (
<div> <div>
<Table <Table
selectAll={true} selectAll={true}
@@ -279,11 +249,10 @@ function Queue() {
onSortPress={handleSortPress} onSortPress={handleSortPress}
> >
<TableBody> <TableBody>
{items.map((item) => { {records.map((item) => {
return ( return (
<QueueRow <QueueRow
key={item.id} key={item.id}
episodeId={item.episodeId}
isSelected={selectedState[item.id]} isSelected={selectedState[item.id]}
columns={columns} columns={columns}
{...item} {...item}
@@ -302,11 +271,7 @@ function Queue() {
totalPages={totalPages} totalPages={totalPages}
totalRecords={totalRecords} totalRecords={totalRecords}
isFetching={isFetching} isFetching={isFetching}
onFirstPagePress={handleFirstPagePress} onPageSelect={goToPage}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/> />
</div> </div>
) : null} ) : null}
@@ -377,7 +342,7 @@ function Queue() {
canChangeCategory={ canChangeCategory={
isConfirmRemoveModalOpen && isConfirmRemoveModalOpen &&
selectedIds.every((id) => { selectedIds.every((id) => {
const item = items.find((i) => i.id === id); const item = records.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory); return !!(item && item.downloadClientHasPostImportCategory);
}) })
@@ -385,7 +350,7 @@ function Queue() {
canIgnore={ canIgnore={
isConfirmRemoveModalOpen && isConfirmRemoveModalOpen &&
selectedIds.every((id) => { selectedIds.every((id) => {
const item = items.find((i) => i.id === id); const item = records.find((i) => i.id === id);
return !!(item && item.seriesId && item.episodeId); return !!(item && item.seriesId && item.episodeId);
}) })
@@ -393,7 +358,7 @@ function Queue() {
isPending={ isPending={
isConfirmRemoveModalOpen && isConfirmRemoveModalOpen &&
selectedIds.every((id) => { selectedIds.every((id) => {
const item = items.find((i) => i.id === id); const item = records.find((i) => i.id === id);
if (!item) { if (!item) {
return false; return false;
+3 -3
View File
@@ -14,7 +14,7 @@ import styles from './QueueDetails.css';
interface QueueDetailsProps { interface QueueDetailsProps {
title: string; title: string;
size: number; size: number;
sizeleft: number; sizeLeft: number;
estimatedCompletionTime?: string; estimatedCompletionTime?: string;
status: string; status: string;
trackedDownloadState?: QueueTrackedDownloadState; trackedDownloadState?: QueueTrackedDownloadState;
@@ -28,7 +28,7 @@ function QueueDetails(props: QueueDetailsProps) {
const { const {
title, title,
size, size,
sizeleft, sizeLeft,
status, status,
trackedDownloadState = 'downloading', trackedDownloadState = 'downloading',
trackedDownloadStatus = 'ok', trackedDownloadStatus = 'ok',
@@ -37,7 +37,7 @@ function QueueDetails(props: QueueDetailsProps) {
progressBar, progressBar,
} = props; } = props;
const progress = 100 - (sizeleft / size) * 100; const progress = 100 - (sizeLeft / size) * 100;
const isDownloading = status === 'downloading'; const isDownloading = status === 'downloading';
const isPaused = status === 'paused'; const isPaused = status === 'paused';
const hasWarning = trackedDownloadStatus === 'warning'; const hasWarning = trackedDownloadStatus === 'warning';
@@ -1,50 +1,26 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal'; import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setQueueFilter } from 'Store/Actions/queueActions'; import { setQueueOption } from './queueOptionsStore';
import useQueue, { FILTER_BUILDER } from './useQueue';
function createQueueSelector() {
return createSelector(
(state: AppState) => state.queue.paged.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.queue.paged.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
type QueueFilterModalProps = FilterModalProps<History>; type QueueFilterModalProps = FilterModalProps<History>;
export default function QueueFilterModal(props: QueueFilterModalProps) { export default function QueueFilterModal(props: QueueFilterModalProps) {
const sectionItems = useSelector(createQueueSelector()); const { records } = useQueue();
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'queue';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback( const dispatchSetFilter = useCallback(
(payload: unknown) => { ({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
dispatch(setQueueFilter(payload)); setQueueOption('selectedFilterKey', selectedFilterKey);
}, },
[dispatch] []
); );
return ( return (
<FilterModal <FilterModal
{...props} {...props}
sectionItems={sectionItems} sectionItems={records}
filterBuilderProps={filterBuilderProps} filterBuilderProps={FILTER_BUILDER}
customFilterType={customFilterType} customFilterType="queue"
dispatchSetFilter={dispatchSetFilter} dispatchSetFilter={dispatchSetFilter}
/> />
); );
+14 -16
View File
@@ -1,33 +1,30 @@
import React, { 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 FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
import { inputTypes } from 'Helpers/Props'; import { inputTypes } from 'Helpers/Props';
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import {
QueueOptions as QueueOptionsType,
setQueueOption,
useQueueOption,
} from './queueOptionsStore';
import useQueue from './useQueue';
function QueueOptions() { function QueueOptions() {
const dispatch = useDispatch(); const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
const { includeUnknownSeriesItems } = useSelector( const { goToPage } = useQueue();
(state: AppState) => state.queue.options
);
const handleOptionChange = useCallback( const handleOptionChange = useCallback(
({ name, value }: InputChanged<boolean>) => { ({ name, value }: OptionChanged<QueueOptionsType>) => {
dispatch( setQueueOption(name, value);
setQueueOption({
[name]: value,
})
);
if (name === 'includeUnknownSeriesItems') { if (name === 'includeUnknownSeriesItems') {
dispatch(gotoQueuePage({ page: 1 })); goToPage(1);
} }
}, },
[dispatch] [goToPage]
); );
return ( return (
@@ -39,6 +36,7 @@ function QueueOptions() {
name="includeUnknownSeriesItems" name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems} value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')} helpText={translate('ShowUnknownSeriesItemsHelpText')}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleOptionChange} onChange={handleOptionChange}
/> />
</FormGroup> </FormGroup>
+65 -66
View File
@@ -1,7 +1,6 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import { Error } from 'App/State/AppSectionState';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar'; import ProgressBar from 'Components/ProgressBar';
@@ -15,16 +14,13 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import EpisodeFormats from 'Episode/EpisodeFormats'; import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages'; import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import useEpisodes from 'Episode/useEpisodes';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import useEpisode from 'Episode/useEpisode';
import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import Language from 'Language/Language'; import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality'; import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink'; import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CustomFormat from 'typings/CustomFormat'; import CustomFormat from 'typings/CustomFormat';
import { SelectStateInputProps } from 'typings/props'; import { SelectStateInputProps } from 'typings/props';
@@ -36,15 +32,18 @@ import {
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EpisodeCellContent from './EpisodeCellContent';
import EpisodeTitleCellContent from './EpisodeTitleCellContent';
import QueueStatusCell from './QueueStatusCell'; import QueueStatusCell from './QueueStatusCell';
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal'; import RemoveQueueItemModal from './RemoveQueueItemModal';
import TimeleftCell from './TimeleftCell'; import TimeLeftCell from './TimeLeftCell';
import { useGrabQueueItem, useRemoveQueueItem } from './useQueue';
import styles from './QueueRow.css'; import styles from './QueueRow.css';
interface QueueRowProps { interface QueueRowProps {
id: number; id: number;
seriesId?: number; seriesId?: number;
episodeId?: number; episodeIds: number[];
downloadId?: string; downloadId?: string;
title: string; title: string;
status: string; status: string;
@@ -58,16 +57,16 @@ interface QueueRowProps {
customFormatScore: number; customFormatScore: number;
protocol: DownloadProtocol; protocol: DownloadProtocol;
indexer?: string; indexer?: string;
isFullSeason: boolean;
seasonNumbers: number[];
outputPath?: string; outputPath?: string;
downloadClient?: string; downloadClient?: string;
downloadClientHasPostImportCategory?: boolean; downloadClientHasPostImportCategory?: boolean;
estimatedCompletionTime?: string; estimatedCompletionTime?: string;
added?: string; added?: string;
timeleft?: string; timeLeft?: string;
size: number; size: number;
sizeleft: number; sizeLeft: number;
isGrabbing?: boolean;
grabError?: Error;
isRemoving?: boolean; isRemoving?: boolean;
isSelected?: boolean; isSelected?: boolean;
columns: Column[]; columns: Column[];
@@ -79,7 +78,7 @@ function QueueRow(props: QueueRowProps) {
const { const {
id, id,
seriesId, seriesId,
episodeId, episodeIds,
downloadId, downloadId,
title, title,
status, status,
@@ -97,25 +96,25 @@ function QueueRow(props: QueueRowProps) {
downloadClient, downloadClient,
downloadClientHasPostImportCategory, downloadClientHasPostImportCategory,
estimatedCompletionTime, estimatedCompletionTime,
isFullSeason,
seasonNumbers,
added, added,
timeleft, timeLeft,
size, size,
sizeleft, sizeLeft,
isGrabbing = false,
grabError,
isRemoving = false,
isSelected, isSelected,
columns, columns,
onSelectedChange, onSelectedChange,
onQueueRowModalOpenOrClose, onQueueRowModalOpenOrClose,
} = props; } = props;
const dispatch = useDispatch();
const series = useSeries(seriesId); const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes'); const episodes = useEpisodes(episodeIds, 'episodes');
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector( const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector() createUISettingsSelector()
); );
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] = const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
useState(false); useState(false);
@@ -124,8 +123,8 @@ function QueueRow(props: QueueRowProps) {
useState(false); useState(false);
const handleGrabPress = useCallback(() => { const handleGrabPress = useCallback(() => {
dispatch(grabQueueItem({ id })); grabQueueItem();
}, [id, dispatch]); }, [grabQueueItem]);
const handleInteractiveImportPress = useCallback(() => { const handleInteractiveImportPress = useCallback(() => {
onQueueRowModalOpenOrClose(true); onQueueRowModalOpenOrClose(true);
@@ -142,21 +141,22 @@ function QueueRow(props: QueueRowProps) {
setIsRemoveQueueItemModalOpen(true); setIsRemoveQueueItemModalOpen(true);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const handleRemoveQueueItemModalConfirmed = useCallback( const handleRemoveQueueItemModalConfirmed = useCallback(() => {
(payload: RemovePressProps) => { onQueueRowModalOpenOrClose(false);
onQueueRowModalOpenOrClose(false); removeQueueItem();
dispatch(removeQueueItem({ id, ...payload })); setIsRemoveQueueItemModalOpen(false);
setIsRemoveQueueItemModalOpen(false); }, [
}, setIsRemoveQueueItemModalOpen,
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch] removeQueueItem,
); onQueueRowModalOpenOrClose,
]);
const handleRemoveQueueItemModalClose = useCallback(() => { const handleRemoveQueueItemModalClose = useCallback(() => {
onQueueRowModalOpenOrClose(false); onQueueRowModalOpenOrClose(false);
setIsRemoveQueueItemModalOpen(false); setIsRemoveQueueItemModalOpen(false);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const progress = 100 - (sizeleft / size) * 100; const progress = 100 - (sizeLeft / size) * 100;
const showInteractiveImport = const showInteractiveImport =
status === 'completed' && trackedDownloadStatus === 'warning'; status === 'completed' && trackedDownloadStatus === 'warning';
const isPending = const isPending =
@@ -209,23 +209,12 @@ function QueueRow(props: QueueRowProps) {
if (name === 'episode') { if (name === 'episode') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
{episode ? ( <EpisodeCellContent
<SeasonEpisodeNumber episodes={episodes}
seasonNumber={episode.seasonNumber} isFullSeason={isFullSeason}
episodeNumber={episode.episodeNumber} seasonNumber={seasonNumbers[0]}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber} series={series}
seriesType={series?.seriesType} />
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={
episode.sceneAbsoluteEpisodeNumber
}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/>
) : (
'-'
)}
</TableRowCell> </TableRowCell>
); );
} }
@@ -233,27 +222,37 @@ function QueueRow(props: QueueRowProps) {
if (name === 'episodes.title') { if (name === 'episodes.title') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
{series && episode ? ( <EpisodeTitleCellContent episodes={episodes} series={series} />
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
) : (
'-'
)}
</TableRowCell> </TableRowCell>
); );
} }
if (name === 'episodes.airDateUtc') { if (name === 'episodes.airDateUtc') {
if (episode) { if (episodes.length === 0) {
return <RelativeDateCell key={name} date={episode.airDateUtc} />; return <TableRowCell key={name}>-</TableRowCell>;
} }
return <TableRowCell key={name}>-</TableRowCell>; if (episodes.length === 1) {
return (
<RelativeDateCell key={name} date={episodes[0].airDateUtc} />
);
}
return (
<TableRowCell key={name}>
<RelativeDateCell
key={name}
component="span"
date={episodes[0].airDateUtc}
/>
{' - '}
<RelativeDateCell
key={name}
component="span"
date={episodes[episodes.length - 1].airDateUtc}
/>
</TableRowCell>
);
} }
if (name === 'languages') { if (name === 'languages') {
@@ -325,13 +324,13 @@ function QueueRow(props: QueueRowProps) {
if (name === 'estimatedCompletionTime') { if (name === 'estimatedCompletionTime') {
return ( return (
<TimeleftCell <TimeLeftCell
key={name} key={name}
status={status} status={status}
estimatedCompletionTime={estimatedCompletionTime} estimatedCompletionTime={estimatedCompletionTime}
timeleft={timeleft} timeLeft={timeLeft}
size={size} size={size}
sizeleft={sizeleft} sizeLeft={sizeLeft}
showRelativeDates={showRelativeDates} showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat} shortDateFormat={shortDateFormat}
timeFormat={timeFormat} timeFormat={timeFormat}
+1 -1
View File
@@ -90,7 +90,7 @@ function QueueStatus(props: QueueStatusProps) {
if (trackedDownloadState === 'importing') { if (trackedDownloadState === 'importing') {
title += ` - ${translate('Importing')}`; title += ` - ${translate('Importing')}`;
iconKind = kinds.PURPLE; iconKind = kinds.PRIMARY;
} }
if (trackedDownloadState === 'failedPending') { if (trackedDownloadState === 'failedPending') {
@@ -1,6 +1,4 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
@@ -11,19 +9,16 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
import { inputTypes, kinds, sizes } from 'Helpers/Props'; import { inputTypes, kinds, sizes } from 'Helpers/Props';
import { setQueueRemovalOption } from 'Store/Actions/queueActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import {
QueueOptions,
setQueueOption,
useQueueOption,
} from './queueOptionsStore';
import styles from './RemoveQueueItemModal.css'; import styles from './RemoveQueueItemModal.css';
export interface RemovePressProps {
remove: boolean;
changeCategory: boolean;
blocklist: boolean;
skipRedownload: boolean;
}
interface RemoveQueueItemModalProps { interface RemoveQueueItemModalProps {
isOpen: boolean; isOpen: boolean;
sourceTitle?: string; sourceTitle?: string;
@@ -31,7 +26,7 @@ interface RemoveQueueItemModalProps {
canIgnore: boolean; canIgnore: boolean;
isPending: boolean; isPending: boolean;
selectedCount?: number; selectedCount?: number;
onRemovePress(props: RemovePressProps): void; onRemovePress(): void;
onModalClose: () => void; onModalClose: () => void;
} }
@@ -47,13 +42,8 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
onModalClose, onModalClose,
} = props; } = props;
const dispatch = useDispatch();
const multipleSelected = selectedCount && selectedCount > 1; const multipleSelected = selectedCount && selectedCount > 1;
const { removalMethod, blocklistMethod } = useQueueOption('removalOptions');
const { removalMethod, blocklistMethod } = useSelector(
(state: AppState) => state.queue.removalOptions
);
const { title, message } = useMemo(() => { const { title, message } = useMemo(() => {
if (!selectedCount) { if (!selectedCount) {
@@ -138,20 +128,19 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
}, [isPending, multipleSelected]); }, [isPending, multipleSelected]);
const handleRemovalOptionInputChange = useCallback( const handleRemovalOptionInputChange = useCallback(
({ name, value }: InputChanged) => { ({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => {
dispatch(setQueueRemovalOption({ [name]: value })); setQueueOption('removalOptions', {
removalMethod,
blocklistMethod,
[name]: value,
});
}, },
[dispatch] [removalMethod, blocklistMethod]
); );
const handleConfirmRemove = useCallback(() => { const handleConfirmRemove = useCallback(() => {
onRemovePress({ onRemovePress();
remove: removalMethod === 'removeFromClient', }, [onRemovePress]);
changeCategory: removalMethod === 'changeCategory',
blocklist: blocklistMethod !== 'doNotBlocklist',
skipRedownload: blocklistMethod === 'blocklistOnly',
});
}, [removalMethod, blocklistMethod, onRemovePress]);
const handleModalClose = useCallback(() => { const handleModalClose = useCallback(() => {
onModalClose(); onModalClose();
@@ -178,6 +167,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
helpTextWarning={translate( helpTextWarning={translate(
'RemoveQueueItemRemovalMethodHelpTextWarning' 'RemoveQueueItemRemovalMethodHelpTextWarning'
)} )}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleRemovalOptionInputChange} onChange={handleRemovalOptionInputChange}
/> />
</FormGroup> </FormGroup>
@@ -196,6 +186,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
value={blocklistMethod} value={blocklistMethod}
values={blocklistMethodOptions} values={blocklistMethodOptions}
helpText={translate('BlocklistReleaseHelpText')} helpText={translate('BlocklistReleaseHelpText')}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleRemovalOptionInputChange} onChange={handleRemovalOptionInputChange}
/> />
</FormGroup> </FormGroup>
@@ -1,33 +1,9 @@
import React, { useEffect } from 'react'; import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import usePrevious from 'Helpers/Hooks/usePrevious'; import useQueueStatus from './useQueueStatus';
import { fetchQueueStatus } from 'Store/Actions/queueActions';
import createQueueStatusSelector from './createQueueStatusSelector';
function QueueStatus() { function QueueStatus() {
const dispatch = useDispatch(); const { errors, warnings, count } = useQueueStatus();
const { isConnected, isReconnecting } = useSelector(
(state: AppState) => state.app
);
const { isPopulated, count, errors, warnings } = useSelector(
createQueueStatusSelector()
);
const wasReconnecting = usePrevious(isReconnecting);
useEffect(() => {
if (!isPopulated) {
dispatch(fetchQueueStatus());
}
}, [isPopulated, dispatch]);
useEffect(() => {
if (isConnected && wasReconnecting) {
dispatch(fetchQueueStatus());
}
}, [isConnected, wasReconnecting, dispatch]);
return ( return (
<PageSidebarStatus count={count} errors={errors} warnings={warnings} /> <PageSidebarStatus count={count} errors={errors} warnings={warnings} />
@@ -1,32 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createQueueStatusSelector() {
return createSelector(
(state: AppState) => state.queue.status.isPopulated,
(state: AppState) => state.queue.status.item,
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
(isPopulated, status, includeUnknownSeriesItems) => {
const {
errors,
warnings,
unknownErrors,
unknownWarnings,
count,
totalCount,
} = status;
return {
...status,
isPopulated,
count: includeUnknownSeriesItems ? totalCount : count,
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
warnings: includeUnknownSeriesItems
? warnings || unknownWarnings
: warnings,
};
}
);
}
export default createQueueStatusSelector;
@@ -0,0 +1,54 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import { useQueueOption } from '../queueOptionsStore';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export default function useQueueStatus() {
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
const { data } = useApiQuery<QueueStatus>({
path: '/queue/status',
queryParams: {
includeUnknownSeriesItems,
},
});
if (!data) {
return {
count: 0,
errors: false,
warnings: false,
};
}
const {
errors,
warnings,
unknownErrors,
unknownWarnings,
count,
totalCount,
} = data;
if (includeUnknownSeriesItems) {
return {
count: totalCount,
errors: errors || unknownErrors,
warnings: warnings || unknownWarnings,
};
}
return {
count,
errors,
warnings,
};
}
@@ -1,4 +1,4 @@
.timeleft { .timeLeft {
composes: cell from '~Components/Table/Cells/TableRowCell.css'; composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px; width: 100px;
@@ -1,7 +1,7 @@
// This file is automatically generated. // This file is automatically generated.
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'timeleft': string; 'timeLeft': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;
export default cssExports; export default cssExports;
@@ -8,26 +8,26 @@ import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import getRelativeDate from 'Utilities/Date/getRelativeDate'; import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './TimeleftCell.css'; import styles from './TimeLeftCell.css';
interface TimeleftCellProps { interface TimeLeftCellProps {
estimatedCompletionTime?: string; estimatedCompletionTime?: string;
timeleft?: string; timeLeft?: string;
status: string; status: string;
size: number; size: number;
sizeleft: number; sizeLeft: number;
showRelativeDates: boolean; showRelativeDates: boolean;
shortDateFormat: string; shortDateFormat: string;
timeFormat: string; timeFormat: string;
} }
function TimeleftCell(props: TimeleftCellProps) { function TimeLeftCell(props: TimeLeftCellProps) {
const { const {
estimatedCompletionTime, estimatedCompletionTime,
timeleft, timeLeft,
status, status,
size, size,
sizeleft, sizeLeft,
showRelativeDates, showRelativeDates,
shortDateFormat, shortDateFormat,
timeFormat, timeFormat,
@@ -44,7 +44,7 @@ function TimeleftCell(props: TimeleftCellProps) {
}); });
return ( return (
<TableRowCell className={styles.timeleft}> <TableRowCell className={styles.timeLeft}>
<Tooltip <Tooltip
anchor={<Icon name={icons.INFO} />} anchor={<Icon name={icons.INFO} />}
tooltip={translate('DelayingDownloadUntil', { date, time })} tooltip={translate('DelayingDownloadUntil', { date, time })}
@@ -66,7 +66,7 @@ function TimeleftCell(props: TimeleftCellProps) {
}); });
return ( return (
<TableRowCell className={styles.timeleft}> <TableRowCell className={styles.timeLeft}>
<Tooltip <Tooltip
anchor={<Icon name={icons.INFO} />} anchor={<Icon name={icons.INFO} />}
tooltip={translate('RetryingDownloadOn', { date, time })} tooltip={translate('RetryingDownloadOn', { date, time })}
@@ -77,21 +77,21 @@ function TimeleftCell(props: TimeleftCellProps) {
); );
} }
if (!timeleft || status === 'completed' || status === 'failed') { if (!timeLeft || status === 'completed' || status === 'failed') {
return <TableRowCell className={styles.timeleft}>-</TableRowCell>; return <TableRowCell className={styles.timeLeft}>-</TableRowCell>;
} }
const totalSize = formatBytes(size); const totalSize = formatBytes(size);
const remainingSize = formatBytes(sizeleft); const remainingSize = formatBytes(sizeLeft);
return ( return (
<TableRowCell <TableRowCell
className={styles.timeleft} className={styles.timeLeft}
title={`${remainingSize} / ${totalSize}`} title={`${remainingSize} / ${totalSize}`}
> >
{formatTimeSpan(timeleft)} {formatTimeSpan(timeLeft)}
</TableRowCell> </TableRowCell>
); );
} }
export default TimeleftCell; export default TimeLeftCell;
@@ -0,0 +1,160 @@
import React from 'react';
import Icon from 'Components/Icon';
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
interface QueueRemovalOptions {
removalMethod: 'changeCategory' | 'ignore' | 'removeFromClient';
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
}
export interface QueueOptions extends PageableOptions {
includeUnknownSeriesItems: boolean;
removalOptions: QueueRemovalOptions;
}
const { useOptions, useOption, setOptions, setOption } =
createOptionsStore<QueueOptions>('queue_options', () => {
return {
includeUnknownSeriesItems: true,
pageSize: 20,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'status',
label: '',
columnLabel: () => translate('Status'),
isSortable: true,
isVisible: true,
isModifiable: false,
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true,
},
{
name: 'episode',
label: () => translate('EpisodeMaybePlural'),
isSortable: true,
isVisible: true,
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitleMaybePlural'),
isSortable: true,
isVisible: true,
},
{
name: 'episodes.airDateUtc',
label: () => translate('EpisodeAirDate'),
isSortable: true,
isVisible: false,
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: true,
isVisible: false,
},
{
name: 'quality',
label: () => translate('Quality'),
isSortable: true,
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true,
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isVisible: false,
},
{
name: 'protocol',
label: () => translate('Protocol'),
isSortable: true,
isVisible: false,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false,
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
isSortable: true,
isVisible: false,
},
{
name: 'title',
label: () => translate('ReleaseTitle'),
isSortable: true,
isVisible: false,
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: false,
},
{
name: 'outputPath',
label: () => translate('OutputPath'),
isSortable: false,
isVisible: false,
},
{
name: 'estimatedCompletionTime',
label: () => translate('TimeLeft'),
isSortable: true,
isVisible: true,
},
{
name: 'added',
label: () => translate('Added'),
isSortable: true,
isVisible: false,
},
{
name: 'progress',
label: () => translate('Progress'),
isSortable: true,
isVisible: true,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
removalOptions: {
removalMethod: 'removeFromClient',
blocklistMethod: 'doNotBlocklist',
},
};
});
export const useQueueOptions = useOptions;
export const setQueueOptions = setOptions;
export const useQueueOption = useOption;
export const setQueueOption = setOption;
+203
View File
@@ -0,0 +1,203 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import Queue from 'typings/Queue';
import getQueryString from 'Utilities/Fetch/getQueryString';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { useQueueOptions } from './queueOptionsStore';
interface BulkQueueData {
ids: number[];
}
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
];
export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
{
name: 'seriesIds',
label: () => translate('Series'),
type: 'equal',
valueType: filterBuilderValueTypes.SERIES,
},
{
name: 'quality',
label: () => translate('Quality'),
type: 'equal',
valueType: filterBuilderValueTypes.QUALITY,
},
{
name: 'languages',
label: () => translate('Languages'),
type: 'contains',
valueType: filterBuilderValueTypes.LANGUAGE,
},
{
name: 'protocol',
label: () => translate('Protocol'),
type: 'equal',
valueType: filterBuilderValueTypes.PROTOCOL,
},
{
name: 'status',
label: () => translate('Status'),
type: 'equal',
valueType: filterBuilderValueTypes.QUEUE_STATUS,
},
];
const useQueue = () => {
const { page, goToPage } = usePage('queue');
const {
includeUnknownSeriesItems,
pageSize,
selectedFilterKey,
sortKey,
sortDirection,
} = useQueueOptions();
const customFilters = useSelector(
createCustomFiltersSelector('queue')
) as CustomFilter[];
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<Queue>({
path: '/queue',
page,
pageSize,
filters,
queryParams: {
includeUnknownSeriesItems,
},
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
return {
...query,
goToPage,
page,
refetch,
};
};
export default useQueue;
export const useFilters = () => {
return FILTERS;
};
const useRemovalOptions = () => {
const { removalOptions } = useQueueOptions();
return {
remove: removalOptions.removalMethod === 'removeFromClient',
changeCategory: removalOptions.removalMethod === 'changeCategory',
blocklist: removalOptions.blocklistMethod !== 'doNotBlocklist',
skipRedownload: removalOptions.blocklistMethod === 'blocklistOnly',
};
};
export const useRemoveQueueItem = (id: number) => {
const queryClient = useQueryClient();
const removalOptions = useRemovalOptions();
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/queue/${id}${getQueryString(removalOptions)}`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
},
});
return {
removeQueueItem: mutate,
isRemoving: isPending,
};
};
export const useRemoveQueueItems = () => {
const queryClient = useQueryClient();
const removalOptions = useRemovalOptions();
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
path: `/queue/bulk${getQueryString(removalOptions)}`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
},
});
return {
removeQueueItems: mutate,
isRemoving: isPending,
};
};
export const useGrabQueueItem = (id: number) => {
const queryClient = useQueryClient();
const [grabError, setGrabError] = useState<string | null>(null);
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/queue/grab/${id}`,
method: 'POST',
mutationOptions: {
onMutate: () => {
setGrabError(null);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
onError: () => {
setGrabError('Error grabbing queue item');
},
},
});
return {
grabQueueItem: mutate,
isGrabbing: isPending,
grabError,
};
};
export const useGrabQueueItems = () => {
const queryClient = useQueryClient();
// Explicitly define the types for the mutation so we can pass in no arguments to mutate as expected.
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
path: '/queue/grab/bulk',
method: 'POST',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
},
});
return {
grabQueueItems: mutate,
isGrabbing: isPending,
};
};
@@ -47,11 +47,7 @@ function AddNewSeriesModalContent({
const { isSmallScreen } = useSelector(createDimensionsSelector()); const { isSmallScreen } = useSelector(createDimensionsSelector());
const isWindows = useIsWindows(); const isWindows = useIsWindows();
const { const { isAdding, addError, addSeries } = useAddSeries();
isPending: isAdding,
error: addError,
mutate: addSeries,
} = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => { const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(options, {}, addError); return selectSettings(options, {}, addError);
@@ -33,11 +33,19 @@ export const useAddSeries = () => {
[dispatch] [dispatch]
); );
return useApiMutation<Series, AddSeriesPayload>({ const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
path: '/series', {
method: 'POST', path: '/series',
mutationOptions: { method: 'POST',
onSuccess: onAddSuccess, mutationOptions: {
}, onSuccess: onAddSuccess,
}); },
}
);
return {
isAdding: isPending,
addError: error,
addSeries: mutate,
};
}; };
@@ -1,4 +1,4 @@
import { createPersist } from 'Helpers/createPersist'; import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { SeriesMonitor, SeriesType } from 'Series/Series'; import { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeriesOptions { export interface AddSeriesOptions {
@@ -12,9 +12,8 @@ export interface AddSeriesOptions {
tags: number[]; tags: number[];
} }
const addSeriesOptionsStore = createPersist<AddSeriesOptions>( const { useOptions, useOption, setOption } =
'add_series_options', createOptionsStore<AddSeriesOptions>('add_series_options', () => {
() => {
return { return {
rootFolderPath: '', rootFolderPath: '',
monitor: 'all', monitor: 'all',
@@ -25,25 +24,8 @@ const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
searchForCutoffUnmetEpisodes: false, searchForCutoffUnmetEpisodes: false,
tags: [], tags: [],
}; };
} });
);
export const useAddSeriesOptions = () => { export const useAddSeriesOptions = useOptions;
return addSeriesOptionsStore((state) => state); export const useAddSeriesOption = useOption;
}; export const setAddSeriesOption = setOption;
export const useAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K
) => {
return addSeriesOptionsStore((state) => state[key]);
};
export const setAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K,
value: AddSeriesOptions[K]
) => {
addSeriesOptionsStore.setState((state) => ({
...state,
[key]: value,
}));
};
+4 -10
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
@@ -9,7 +9,6 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious'; import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { fetchUpdates } from 'Store/Actions/systemActions';
import UpdateChanges from 'System/Updates/UpdateChanges'; import UpdateChanges from 'System/Updates/UpdateChanges';
import useUpdates from 'System/Updates/useUpdates'; import useUpdates from 'System/Updates/useUpdates';
import Update from 'typings/Update'; import Update from 'typings/Update';
@@ -64,9 +63,8 @@ interface AppUpdatedModalContentProps {
} }
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) { function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const dispatch = useDispatch();
const { version, prevVersion } = useSelector((state: AppState) => state.app); const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { isFetched, error, data } = useUpdates(); const { isFetched, error, data, refetch } = useUpdates();
const previousVersion = usePrevious(version); const previousVersion = usePrevious(version);
const { onModalClose } = props; const { onModalClose } = props;
@@ -77,15 +75,11 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
window.location.href = `${window.Sonarr.urlBase}/system/updates`; window.location.href = `${window.Sonarr.urlBase}/system/updates`;
}, []); }, []);
useEffect(() => {
dispatch(fetchUpdates());
}, [dispatch]);
useEffect(() => { useEffect(() => {
if (version !== previousVersion) { if (version !== previousVersion) {
dispatch(fetchUpdates()); refetch();
} }
}, [version, previousVersion, dispatch]); }, [version, previousVersion, refetch]);
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
-2
View File
@@ -18,7 +18,6 @@ import OrganizePreviewAppState from './OrganizePreviewAppState';
import ParseAppState from './ParseAppState'; import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState'; import PathsAppState from './PathsAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState'; import ProviderOptionsAppState from './ProviderOptionsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState'; import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState'; import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState'; import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
@@ -99,7 +98,6 @@ interface AppState {
parse: ParseAppState; parse: ParseAppState;
paths: PathsAppState; paths: PathsAppState;
providerOptions: ProviderOptionsAppState; providerOptions: ProviderOptionsAppState;
queue: QueueAppState;
releases: ReleasesAppState; releases: ReleasesAppState;
rootFolders: RootFolderAppState; rootFolders: RootFolderAppState;
series: SeriesAppState; series: SeriesAppState;
-56
View File
@@ -1,56 +0,0 @@
import Queue from 'typings/Queue';
import AppSectionState, {
AppSectionFilterState,
AppSectionItemState,
Error,
PagedAppSectionState,
TableAppSectionState,
} from './AppSectionState';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown;
}
export interface QueuePagedAppState
extends AppSectionState<Queue>,
AppSectionFilterState<Queue>,
PagedAppSectionState,
TableAppSectionState {
isGrabbing: boolean;
grabError: Error;
isRemoving: boolean;
removeError: Error;
}
export type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
export type BlocklistMethod =
| 'doNotBlocklist'
| 'blocklistAndSearch'
| 'blocklistOnly';
interface RemovalOptions {
removalMethod: RemovalMethod;
blocklistMethod: BlocklistMethod;
}
interface QueueAppState {
status: AppSectionItemState<QueueStatus>;
details: QueueDetailsAppState;
paged: QueuePagedAppState;
options: {
includeUnknownSeriesItems: boolean;
};
removalOptions: RemovalOptions;
}
export default QueueAppState;
+1
View File
@@ -17,6 +17,7 @@ export interface TagDetail extends ModelBase {
indexerIds: number[]; indexerIds: number[];
notificationIds: number[]; notificationIds: number[];
restrictionIds: number[]; restrictionIds: number[];
excludedReleaseProfileIds: number[];
seriesIds: number[]; seriesIds: number[];
} }
+2 -2
View File
@@ -2,6 +2,7 @@ import classNames from 'classnames';
import moment from 'moment'; import moment from 'moment';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
import getStatusStyle from 'Calendar/getStatusStyle'; import getStatusStyle from 'Calendar/getStatusStyle';
@@ -13,7 +14,6 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime'; import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber'; import padNumber from 'Utilities/Number/padNumber';
@@ -57,7 +57,7 @@ function AgendaEvent(props: AgendaEventProps) {
const series = useSeries(seriesId)!; const series = useSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId); const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id)); const queueItem = useQueueItemForEpisode(id);
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector( const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector() createUISettingsSelector()
); );
-14
View File
@@ -17,10 +17,6 @@ import {
clearEpisodeFiles, clearEpisodeFiles,
fetchEpisodeFiles, fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions'; } from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
@@ -74,7 +70,6 @@ function Calendar() {
return () => { return () => {
dispatch(clearCalendar()); dispatch(clearCalendar());
dispatch(clearQueueDetails());
dispatch(clearEpisodeFiles()); dispatch(clearEpisodeFiles());
clearTimeout(updateTimeout.current); clearTimeout(updateTimeout.current);
}; };
@@ -90,7 +85,6 @@ function Calendar() {
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
dispatch(fetchQueueDetails({ time, view }));
dispatch(fetchCalendar({ time, view })); dispatch(fetchCalendar({ time, view }));
}; };
@@ -125,16 +119,11 @@ function Calendar() {
useEffect(() => { useEffect(() => {
if (!previousItems || hasDifferentItems(items, previousItems)) { if (!previousItems || hasDifferentItems(items, previousItems)) {
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
const episodeFileIds = selectUniqueIds<Episode, number>( const episodeFileIds = selectUniqueIds<Episode, number>(
items, items,
'episodeFileId' 'episodeFileId'
); );
if (items.length) {
dispatch(fetchQueueDetails({ episodeIds }));
}
if (episodeFileIds.length) { if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds })); dispatch(fetchEpisodeFiles({ episodeFileIds }));
} }
@@ -144,18 +133,15 @@ function Calendar() {
return ( return (
<div className={styles.calendar}> <div className={styles.calendar}>
{isFetching && !isPopulated ? <LoadingIndicator /> : null} {isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && error ? ( {!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert> <Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
) : null} ) : null}
{!error && isPopulated && view === 'agenda' ? ( {!error && isPopulated && view === 'agenda' ? (
<div className={styles.calendarContent}> <div className={styles.calendarContent}>
<CalendarHeader /> <CalendarHeader />
<Agenda /> <Agenda />
</div> </div>
) : null} ) : null}
{!error && isPopulated && view !== 'agenda' ? ( {!error && isPopulated && view !== 'agenda' ? (
<div className={styles.calendarContent}> <div className={styles.calendarContent}>
<CalendarHeader /> <CalendarHeader />
@@ -0,0 +1,78 @@
import moment from 'moment';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import Queue from 'typings/Queue';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate';
function createIsSearchingSelector() {
return createSelector(
(state: AppState) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(
commands.find((command) => {
return command.id === searchMissingCommandId;
})
);
}
);
}
function createMissingEpisodeIdsSelector(queueDetails: Queue[]) {
return createSelector(
(state: AppState) => state.calendar.start,
(state: AppState) => state.calendar.end,
(state: AppState) => state.calendar.items,
(start, end, episodes) => {
return episodes.reduce<number[]>((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some(
(details) => !!details.episode && details.episode.id === episode.id
)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
export default function CalendarMissingEpisodeSearchButton() {
const queueDetails = useQueueDetails();
const missingEpisodeIds = useSelector(
createMissingEpisodeIdsSelector(queueDetails)
);
const isSearchingForMissing = useSelector(createIsSearchingSelector());
const handlePress = useCallback(() => {}, []);
return (
<PageToolbarButton
label={translate('SearchForMissing')}
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={handlePress}
/>
);
}
+63 -117
View File
@@ -1,7 +1,6 @@
import moment from 'moment'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import FilterMenu from 'Components/Menu/FilterMenu'; import FilterMenu from 'Components/Menu/FilterMenu';
@@ -11,24 +10,23 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Episode from 'Episode/Episode';
import useMeasure from 'Helpers/Hooks/useMeasure'; import useMeasure from 'Helpers/Hooks/useMeasure';
import { align, icons } from 'Helpers/Props'; import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries'; import NoSeries from 'Series/NoSeries';
import { import {
searchMissing,
setCalendarDaysCount, setCalendarDaysCount,
setCalendarFilter, setCalendarFilter,
} from 'Store/Actions/calendarActions'; } from 'Store/Actions/calendarActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
import { isCommandExecuting } from 'Utilities/Command'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import Calendar from './Calendar'; import Calendar from './Calendar';
import CalendarFilterModal from './CalendarFilterModal'; import CalendarFilterModal from './CalendarFilterModal';
import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton';
import CalendarLinkModal from './iCal/CalendarLinkModal'; import CalendarLinkModal from './iCal/CalendarLinkModal';
import Legend from './Legend/Legend'; import Legend from './Legend/Legend';
import CalendarOptionsModal from './Options/CalendarOptionsModal'; import CalendarOptionsModal from './Options/CalendarOptionsModal';
@@ -36,60 +34,12 @@ import styles from './CalendarPage.css';
const MINIMUM_DAY_WIDTH = 120; const MINIMUM_DAY_WIDTH = 120;
function createMissingEpisodeIdsSelector() {
return createSelector(
(state: AppState) => state.calendar.start,
(state: AppState) => state.calendar.end,
(state: AppState) => state.calendar.items,
(state: AppState) => state.queue.details.items,
(start, end, episodes, queueDetails) => {
return episodes.reduce<number[]>((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some(
(details) => !!details.episode && details.episode.id === episode.id
)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
function createIsSearchingSelector() {
return createSelector(
(state: AppState) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(
commands.find((command) => {
return command.id === searchMissingCommandId;
})
);
}
);
}
function CalendarPage() { function CalendarPage() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { selectedFilterKey, filters } = useSelector( const { selectedFilterKey, filters, items } = useSelector(
(state: AppState) => state.calendar (state: AppState) => state.calendar
); );
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
const isSearchingForMissing = useSelector(createIsSearchingSelector());
const isRssSyncExecuting = useSelector( const isRssSyncExecuting = useSelector(
createCommandExecutingSelector(commandNames.RSS_SYNC) createCommandExecutingSelector(commandNames.RSS_SYNC)
); );
@@ -127,10 +77,6 @@ function CalendarPage() {
); );
}, [dispatch]); }, [dispatch]);
const handleSearchMissingPress = useCallback(() => {
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
}, [missingEpisodeIds, dispatch]);
const handleFilterSelect = useCallback( const handleFilterSelect = useCallback(
(key: string | number) => { (key: string | number) => {
dispatch(setCalendarFilter({ selectedFilterKey: key })); dispatch(setCalendarFilter({ selectedFilterKey: key }));
@@ -138,6 +84,10 @@ function CalendarPage() {
[dispatch] [dispatch]
); );
const episodeIds = useMemo(() => {
return selectUniqueIds<Episode, number>(items, 'id');
}, [items]);
useEffect(() => { useEffect(() => {
if (width === 0) { if (width === 0) {
return; return;
@@ -152,71 +102,67 @@ function CalendarPage() {
}, [width, dispatch]); }, [width, dispatch]);
return ( return (
<PageContent title={translate('Calendar')}> <QueueDetails episodeIds={episodeIds}>
<PageToolbar> <PageContent title={translate('Calendar')}>
<PageToolbarSection> <PageToolbar>
<PageToolbarButton <PageToolbarSection>
label={translate('ICalLink')} <PageToolbarButton
iconName={icons.CALENDAR} label={translate('ICalLink')}
onPress={handleGetCalendarLinkPress} iconName={icons.CALENDAR}
/> onPress={handleGetCalendarLinkPress}
/>
<PageToolbarSeparator /> <PageToolbarSeparator />
<PageToolbarButton <PageToolbarButton
label={translate('RssSync')} label={translate('RssSync')}
iconName={icons.RSS} iconName={icons.RSS}
isSpinning={isRssSyncExecuting} isSpinning={isRssSyncExecuting}
onPress={handleRssSyncPress} onPress={handleRssSyncPress}
/> />
<PageToolbarButton <CalendarMissingEpisodeSearchButton />
label={translate('SearchForMissing')} </PageToolbarSection>
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={handleSearchMissingPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}> <PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton <PageToolbarButton
label={translate('Options')} label={translate('Options')}
iconName={icons.POSTER} iconName={icons.POSTER}
onPress={handleOptionsPress} onPress={handleOptionsPress}
/> />
<FilterMenu <FilterMenu
alignMenu={align.RIGHT} alignMenu={align.RIGHT}
isDisabled={!hasSeries} isDisabled={!hasSeries}
selectedFilterKey={selectedFilterKey} selectedFilterKey={selectedFilterKey}
filters={filters} filters={filters}
customFilters={customFilters} customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal} filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={handleFilterSelect} onFilterSelect={handleFilterSelect}
/> />
</PageToolbarSection> </PageToolbarSection>
</PageToolbar> </PageToolbar>
<PageContentBody <PageContentBody
ref={pageContentRef} ref={pageContentRef}
className={styles.calendarPageBody} className={styles.calendarPageBody}
innerClassName={styles.calendarInnerPageBody} innerClassName={styles.calendarInnerPageBody}
> >
{isMeasured ? <PageComponent totalItems={0} /> : <div />} {isMeasured ? <PageComponent totalItems={0} /> : <div />}
{hasSeries && <Legend />} {hasSeries && <Legend />}
</PageContentBody> </PageContentBody>
<CalendarLinkModal <CalendarLinkModal
isOpen={isCalendarLinkModalOpen} isOpen={isCalendarLinkModalOpen}
onModalClose={handleGetCalendarLinkModalClose} onModalClose={handleGetCalendarLinkModalClose}
/> />
<CalendarOptionsModal <CalendarOptionsModal
isOpen={isOptionsModalOpen} isOpen={isOptionsModalOpen}
onModalClose={handleOptionsModalClose} onModalClose={handleOptionsModalClose}
/> />
</PageContent> </PageContent>
</QueueDetails>
); );
} }
@@ -2,6 +2,7 @@ import classNames from 'classnames';
import moment from 'moment'; import moment from 'moment';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle'; import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@@ -12,7 +13,6 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime'; import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber'; import padNumber from 'Utilities/Number/padNumber';
@@ -58,7 +58,7 @@ function CalendarEvent(props: CalendarEventProps) {
const series = useSeries(seriesId); const series = useSeries(seriesId);
const episodeFile = useEpisodeFile(episodeFileId); const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id)); const queueItem = useQueueItemForEpisode(id);
const { timeFormat, enableColorImpairedMode } = useSelector( const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector() createUISettingsSelector()
@@ -2,7 +2,7 @@ import classNames from 'classnames';
import moment from 'moment'; import moment from 'moment';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle'; import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@@ -18,17 +18,6 @@ import translate from 'Utilities/String/translate';
import CalendarEvent from './CalendarEvent'; import CalendarEvent from './CalendarEvent';
import styles from './CalendarEventGroup.css'; import styles from './CalendarEventGroup.css';
function createIsDownloadingSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.queue.details,
(details) => {
return details.items.some(
(item) => item.episodeId && episodeIds.includes(item.episodeId)
);
}
);
}
interface CalendarEventGroupProps { interface CalendarEventGroupProps {
episodeIds: number[]; episodeIds: number[];
seriesId: number; seriesId: number;
@@ -42,7 +31,7 @@ function CalendarEventGroup({
events, events,
onEventModalOpenToggle, onEventModalOpenToggle,
}: CalendarEventGroupProps) { }: CalendarEventGroupProps) {
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds)); const isDownloading = useIsDownloadingEpisodes(episodeIds);
const series = useSeries(seriesId)!; const series = useSeries(seriesId)!;
const { timeFormat, enableColorImpairedMode } = useSelector( const { timeFormat, enableColorImpairedMode } = useSelector(
@@ -10,7 +10,7 @@ import {
interface CalendarEventQueueDetailsProps { interface CalendarEventQueueDetailsProps {
title: string; title: string;
size: number; size: number;
sizeleft: number; sizeLeft: number;
estimatedCompletionTime?: string; estimatedCompletionTime?: string;
status: string; status: string;
trackedDownloadState: QueueTrackedDownloadState; trackedDownloadState: QueueTrackedDownloadState;
@@ -22,7 +22,7 @@ interface CalendarEventQueueDetailsProps {
function CalendarEventQueueDetails({ function CalendarEventQueueDetails({
title, title,
size, size,
sizeleft, sizeLeft,
estimatedCompletionTime, estimatedCompletionTime,
status, status,
trackedDownloadState, trackedDownloadState,
@@ -30,13 +30,13 @@ function CalendarEventQueueDetails({
statusMessages, statusMessages,
errorMessage, errorMessage,
}: CalendarEventQueueDetailsProps) { }: CalendarEventQueueDetailsProps) {
const progress = size ? 100 - (sizeleft / size) * 100 : 0; const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
return ( return (
<QueueDetails <QueueDetails
title={title} title={title}
size={size} size={size}
sizeleft={sizeleft} sizeLeft={sizeLeft}
estimatedCompletionTime={estimatedCompletionTime} estimatedCompletionTime={estimatedCompletionTime}
status={status} status={status}
trackedDownloadState={trackedDownloadState} trackedDownloadState={trackedDownloadState}
@@ -0,0 +1,19 @@
import React from 'react';
import NumberInput, { NumberInputChanged } from './NumberInput';
export interface FloatInputProps {
name: string;
value?: number | null;
min?: number;
max?: number;
step?: number;
placeholder?: string;
className?: string;
onChange: (change: NumberInputChanged) => void;
}
function FloatInput(props: FloatInputProps) {
return <NumberInput {...props} isFloat={true} />;
}
export default FloatInput;
@@ -7,6 +7,7 @@ import translate from 'Utilities/String/translate';
import AutoCompleteInput, { AutoCompleteInputProps } from './AutoCompleteInput'; import AutoCompleteInput, { AutoCompleteInputProps } from './AutoCompleteInput';
import CaptchaInput, { CaptchaInputProps } from './CaptchaInput'; import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
import CheckInput, { CheckInputProps } from './CheckInput'; import CheckInput, { CheckInputProps } from './CheckInput';
import FloatInput, { FloatInputProps } from './FloatInput';
import { FormInputButtonProps } from './FormInputButton'; import { FormInputButtonProps } from './FormInputButton';
import FormInputHelpText from './FormInputHelpText'; import FormInputHelpText from './FormInputHelpText';
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput'; import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
@@ -65,7 +66,7 @@ const componentMap: Record<InputType, ElementType> = {
downloadClientSelect: DownloadClientSelectInput, downloadClientSelect: DownloadClientSelectInput,
dynamicSelect: ProviderDataSelectInput, dynamicSelect: ProviderDataSelectInput,
file: TextInput, file: TextInput,
float: NumberInput, float: FloatInput,
indexerFlagsSelect: IndexerFlagsSelectInput, indexerFlagsSelect: IndexerFlagsSelectInput,
indexerSelect: IndexerSelectInput, indexerSelect: IndexerSelectInput,
keyValueList: KeyValueListInput, keyValueList: KeyValueListInput,
@@ -110,7 +111,7 @@ type PickProps<V, C extends InputType> = C extends 'text'
: C extends 'file' : C extends 'file'
? TextInputProps ? TextInputProps
: C extends 'float' : C extends 'float'
? TextInputProps ? FloatInputProps
: C extends 'indexerFlagsSelect' : C extends 'indexerFlagsSelect'
? IndexerFlagsSelectInputProps ? IndexerFlagsSelectInputProps
: C extends 'indexerSelect' : C extends 'indexerSelect'
+11 -3
View File
@@ -24,13 +24,17 @@ function parseValue(
return newValue; return newValue;
} }
export interface NumberInputChanged extends InputChanged<number | null> {
isFloat?: boolean;
}
export interface NumberInputProps export interface NumberInputProps
extends Omit<TextInputProps, 'value' | 'onChange'> { extends Omit<TextInputProps, 'value' | 'onChange'> {
value?: number | null; value?: number | null;
min?: number; min?: number;
max?: number; max?: number;
isFloat?: boolean; isFloat?: boolean;
onChange: (input: InputChanged<number | null>) => void; onChange: (change: NumberInputChanged) => void;
} }
function NumberInput({ function NumberInput({
@@ -50,11 +54,14 @@ function NumberInput({
const handleChange = useCallback( const handleChange = useCallback(
({ name, value: newValue }: InputChanged<string>) => { ({ name, value: newValue }: InputChanged<string>) => {
setValue(newValue); const parsedValue = parseValue(newValue, isFloat, min, max);
setValue(parsedValue == null ? '' : parsedValue.toString());
onChange({ onChange({
name, name,
value: parseValue(newValue, isFloat, min, max), value: parsedValue,
isFloat,
}); });
}, },
[isFloat, min, max, onChange, setValue] [isFloat, min, max, onChange, setValue]
@@ -75,6 +82,7 @@ function NumberInput({
onChange({ onChange({
name, name,
value: parsedValue, value: parsedValue,
isFloat,
}); });
isFocused.current = false; isFocused.current = false;
@@ -5,14 +5,18 @@ import { addTag } from 'Store/Actions/tagActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { InputChanged } from 'typings/inputs'; import { InputChanged } from 'typings/inputs';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByProp from 'Utilities/Array/sortByProp';
import TagInput, { TagBase } from './TagInput'; import TagInput, { TagBase, TagInputProps } from './TagInput';
interface SeriesTag extends TagBase { interface SeriesTag extends TagBase {
id: number; id: number;
name: string; name: string;
} }
export interface SeriesTagInputProps<V> { export interface SeriesTagInputProps<V>
extends Omit<
TagInputProps<SeriesTag>,
'tags' | 'tagList' | 'onTagAdd' | 'onTagDelete' | 'onChange'
> {
name: string; name: string;
value: V; value: V;
onChange: (change: InputChanged<V>) => void; onChange: (change: InputChanged<V>) => void;
@@ -63,6 +67,7 @@ export default function SeriesTagInput<V extends number | number[]>({
name, name,
value, value,
onChange, onChange,
...otherProps
}: SeriesTagInputProps<V>) { }: SeriesTagInputProps<V>) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isArray = Array.isArray(value); const isArray = Array.isArray(value);
@@ -135,6 +140,7 @@ export default function SeriesTagInput<V extends number | number[]>({
return ( return (
<TagInput <TagInput
{...otherProps}
name={name} name={name}
tags={tags} tags={tags}
tagList={tagList} tagList={tagList}
+4
View File
@@ -26,6 +26,10 @@
color: var(--warningColor); color: var(--warningColor);
} }
.primary {
color: var(--primaryColor);
}
.purple { .purple {
color: var(--purple); color: var(--purple);
} }
+1
View File
@@ -6,6 +6,7 @@ interface CssExports {
'disabled': string; 'disabled': string;
'info': string; 'info': string;
'pink': string; 'pink': string;
'primary': string;
'purple': string; 'purple': string;
'success': string; 'success': string;
'warning': string; 'warning': string;
+1
View File
@@ -19,6 +19,7 @@
.modal { .modal {
position: relative; position: relative;
display: flex; display: flex;
max-width: 90%;
max-height: 90%; max-height: 90%;
border-radius: 6px; border-radius: 6px;
opacity: 1; opacity: 1;
+32 -14
View File
@@ -3,19 +3,18 @@ import {
HubConnectionBuilder, HubConnectionBuilder,
LogLevel, LogLevel,
} from '@microsoft/signalr'; } from '@microsoft/signalr';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch } from 'react-redux';
import ModelBase from 'App/ModelBase'; import ModelBase from 'App/ModelBase';
import AppState from 'App/State/AppState';
import Command from 'Commands/Command'; import Command from 'Commands/Command';
import { setAppValue, setVersion } from 'Store/Actions/appActions'; import { setAppValue, setVersion } from 'Store/Actions/appActions';
import { removeItem, update, updateItem } from 'Store/Actions/baseActions'; import { removeItem, updateItem } from 'Store/Actions/baseActions';
import { import {
fetchCommands, fetchCommands,
finishCommand, finishCommand,
updateCommand, updateCommand,
} from 'Store/Actions/commandActions'; } from 'Store/Actions/commandActions';
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchSeries } from 'Store/Actions/seriesActions'; import { fetchSeries } from 'Store/Actions/seriesActions';
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions'; import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
@@ -33,15 +32,13 @@ interface SignalRMessage {
resource: ModelBase; resource: ModelBase;
version: string; version: string;
}; };
version: number | undefined;
} }
function SignalRListener() { function SignalRListener() {
const queryClient = useQueryClient();
const dispatch = useDispatch(); const dispatch = useDispatch();
const isQueuePopulated = useSelector(
(state: AppState) => state.queue.paged.isPopulated
);
const connection = useRef<HubConnection | null>(null); const connection = useRef<HubConnection | null>(null);
const handleStartFail = useRef((error: unknown) => { const handleStartFail = useRef((error: unknown) => {
@@ -97,9 +94,14 @@ function SignalRListener() {
}); });
const handleReceiveMessage = useRef((message: SignalRMessage) => { const handleReceiveMessage = useRef((message: SignalRMessage) => {
console.debug('[signalR] received', message.name, message.body); console.debug(
`[signalR] received ${message.name}${
message.version ? ` v${message.version}` : ''
}`,
message.body
);
const { name, body } = message; const { name, body, version = 0 } = message;
if (name === 'calendar') { if (name === 'calendar') {
if (body.action === 'updated') { if (body.action === 'updated') {
@@ -235,20 +237,36 @@ function SignalRListener() {
} }
if (name === 'queue') { if (name === 'queue') {
if (isQueuePopulated) { if (version < 5) {
dispatch(fetchQueue()); return;
} }
queryClient.invalidateQueries({ queryKey: ['/queue'] });
return; return;
} }
if (name === 'queue/details') { if (name === 'queue/details') {
dispatch(fetchQueueDetails()); if (version < 5) {
return;
}
queryClient.invalidateQueries({ queryKey: ['/queue/details'] });
return; return;
} }
if (name === 'queue/status') { if (name === 'queue/status') {
dispatch(update({ section: 'queue.status', data: body.resource })); if (version < 5) {
return;
}
const statusDetails = queryClient.getQueriesData({
queryKey: ['/queue/status'],
});
statusDetails.forEach(([queryKey]) => {
queryClient.setQueryData(queryKey, () => body.resource);
});
return; return;
} }
@@ -20,7 +20,6 @@ function RelativeDateCell(props: RelativeDateCellProps) {
date, date,
includeSeconds = false, includeSeconds = false,
includeTime = false, includeTime = false,
component: Component = TableRowCell, component: Component = TableRowCell,
...otherProps ...otherProps
} = props; } = props;
+9 -5
View File
@@ -1,16 +1,22 @@
import React from 'react'; import React from 'react';
import { Tag } from 'App/State/TagsAppState'; import { Tag } from 'App/State/TagsAppState';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByProp from 'Utilities/Array/sortByProp';
import Label from './Label'; import Label, { LabelProps } from './Label';
import styles from './TagList.css'; import styles from './TagList.css';
interface TagListProps { interface TagListProps {
tags: number[]; tags: number[];
tagList: Tag[]; tagList: Tag[];
kind?: Extract<Kind, LabelProps['kind']>;
} }
function TagList({ tags, tagList }: TagListProps) { export default function TagList({
tags,
tagList,
kind = kinds.INFO,
}: TagListProps) {
const sortedTags = tags const sortedTags = tags
.map((tagId) => tagList.find((tag) => tag.id === tagId)) .map((tagId) => tagList.find((tag) => tag.id === tagId))
.filter((tag) => !!tag) .filter((tag) => !!tag)
@@ -20,7 +26,7 @@ function TagList({ tags, tagList }: TagListProps) {
<div className={styles.tags}> <div className={styles.tags}>
{sortedTags.map((tag) => { {sortedTags.map((tag) => {
return ( return (
<Label key={tag.id} kind={kinds.INFO}> <Label key={tag.id} kind={kind}>
{tag.label} {tag.label}
</Label> </Label>
); );
@@ -28,5 +34,3 @@ function TagList({ tags, tagList }: TagListProps) {
</div> </div>
); );
} }
export default TagList;
+4 -5
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import QueueDetails from 'Activity/Queue/QueueDetails'; import QueueDetails from 'Activity/Queue/QueueDetails';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import ProgressBar from 'Components/ProgressBar'; import ProgressBar from 'Components/ProgressBar';
@@ -7,7 +7,6 @@ import Episode from 'Episode/Episode';
import useEpisode, { EpisodeEntity } from 'Episode/useEpisode'; import useEpisode, { EpisodeEntity } from 'Episode/useEpisode';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds, sizes } from 'Helpers/Props'; import { icons, kinds, sizes } from 'Helpers/Props';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import isBefore from 'Utilities/Date/isBefore'; import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EpisodeQuality from './EpisodeQuality'; import EpisodeQuality from './EpisodeQuality';
@@ -30,7 +29,7 @@ function EpisodeStatus({
grabbed = false, grabbed = false,
} = useEpisode(episodeId, episodeEntity) as Episode; } = useEpisode(episodeId, episodeEntity) as Episode;
const queueItem = useSelector(createQueueItemSelectorForHook(episodeId)); const queueItem = useQueueItemForEpisode(episodeId);
const episodeFile = useEpisodeFile(episodeFileId); const episodeFile = useEpisodeFile(episodeFileId);
const hasEpisodeFile = !!episodeFile; const hasEpisodeFile = !!episodeFile;
@@ -38,9 +37,9 @@ function EpisodeStatus({
const hasAired = isBefore(airDateUtc); const hasAired = isBefore(airDateUtc);
if (isQueued) { 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 ( return (
<div className={styles.center}> <div className={styles.center}>
+82
View File
@@ -0,0 +1,82 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Episode from './Episode';
export type EpisodeEntity =
| 'calendar'
| 'episodes'
| 'interactiveImport.episodes'
| 'wanted.cutoffUnmet'
| 'wanted.missing';
function getEpisodes(episodeIds: number[], episodes: Episode[]) {
return episodeIds.reduce<Episode[]>((acc, id) => {
const episode = episodes.find((episode) => episode.id === id);
if (episode) {
acc.push(episode);
}
return acc;
}, []);
}
function createEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.episodes.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
function createCalendarEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.calendar.items as Episode[],
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
function createWantedCutoffUnmetEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.wanted.cutoffUnmet.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
function createWantedMissingEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.wanted.missing.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
export default function useEpisodes(
episodeIds: number[],
episodeEntity: EpisodeEntity
) {
let selector = createEpisodeSelector;
switch (episodeEntity) {
case 'calendar':
selector = createCalendarEpisodeSelector;
break;
case 'wanted.cutoffUnmet':
selector = createWantedCutoffUnmetEpisodeSelector;
break;
case 'wanted.missing':
selector = createWantedMissingEpisodeSelector;
break;
default:
break;
}
return useSelector(selector(episodeIds));
}
@@ -54,7 +54,7 @@ export default function AuthenticationRequiredModalContent() {
dispatch(fetchGeneralSettings()); dispatch(fetchGeneralSettings());
return () => { return () => {
dispatch(clearPendingChanges()); dispatch(clearPendingChanges({ section: `settings.${SECTION}` }));
}; };
}, [dispatch]); }, [dispatch]);
+11 -7
View File
@@ -1,33 +1,37 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'; import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Error } from 'App/State/AppSectionState'; import { Error } from 'App/State/AppSectionState';
import fetchJson, { import fetchJson, { FetchJsonOptions } from 'Utilities/Fetch/fetchJson';
apiRoot, import getQueryPath from 'Utilities/Fetch/getQueryPath';
FetchJsonOptions, import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
} from 'Utilities/Fetch/fetchJson';
interface MutationOptions<T, TData> interface MutationOptions<T, TData>
extends Omit<FetchJsonOptions<TData>, 'method'> { extends Omit<FetchJsonOptions<TData>, 'method'> {
method: 'POST' | 'PUT' | 'DELETE'; method: 'POST' | 'PUT' | 'DELETE';
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>; mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
queryParams?: QueryParams;
} }
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) { function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
const requestOptions = useMemo(() => { const requestOptions = useMemo(() => {
return { return {
...options, ...options,
path: apiRoot + options.path, path: getQueryPath(options.path) + getQueryString(options.queryParams),
headers: { headers: {
...options.headers, ...options.headers,
'X-Api-Key': window.Sonarr.apiKey, 'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
}, },
}; };
}, [options]); }, [options]);
return useMutation<T, Error, TData>({ return useMutation<T, Error, TData>({
...options.mutationOptions, ...options.mutationOptions,
mutationFn: async (data: TData) => mutationFn: async (data?: TData) => {
fetchJson<T, TData>({ ...requestOptions, body: data }), const { path, ...otherOptions } = requestOptions;
return fetchJson<T, TData>({ path, ...otherOptions, body: data });
},
}); });
} }
+11 -7
View File
@@ -15,22 +15,26 @@ export interface QueryOptions<T> extends FetchJsonOptions<unknown> {
} }
const useApiQuery = <T>(options: QueryOptions<T>) => { const useApiQuery = <T>(options: QueryOptions<T>) => {
const requestOptions = useMemo(() => { const { queryKey, requestOptions } = useMemo(() => {
const { path: path, queryOptions, queryParams, ...otherOptions } = options; const { path: path, queryOptions, queryParams, ...otherOptions } = options;
return { return {
...otherOptions, queryKey: [path, queryParams],
path: getQueryPath(path) + getQueryString(queryParams), requestOptions: {
headers: { ...otherOptions,
...options.headers, path: getQueryPath(path) + getQueryString(queryParams),
'X-Api-Key': window.Sonarr.apiKey, headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
}, },
}; };
}, [options]); }, [options]);
return useQuery({ return useQuery({
...options.queryOptions, ...options.queryOptions,
queryKey: [requestOptions.path], queryKey,
queryFn: async ({ signal }) => queryFn: async ({ signal }) =>
fetchJson<T, unknown>({ ...requestOptions, signal }), fetchJson<T, unknown>({ ...requestOptions, signal }),
}); });
@@ -0,0 +1,137 @@
import { StateCreator } from 'zustand';
import { PersistOptions } from 'zustand/middleware';
import Column from 'Components/Table/Column';
import { createPersist } from 'Helpers/createPersist';
import { SortDirection } from 'Helpers/Props/sortDirections';
type TSettingsWithoutColumns = object;
interface TSettingsWithColumns {
columns: Column[];
}
type TSettingd = TSettingsWithoutColumns | TSettingsWithColumns;
export interface PageableOptions {
pageSize: number;
selectedFilterKey: string | number;
sortKey: string;
sortDirection: SortDirection;
columns: Column[];
}
export type OptionChanged<T> = {
name: keyof T;
value: T[keyof T];
};
export const createOptionsStore = <T extends TSettingd>(
name: string,
state: StateCreator<T>,
options: Omit<PersistOptions<T>, 'name' | 'storage'> = {}
) => {
const store = createPersist<T>(name, state, {
merge,
...options,
});
const useOptions = () => {
return store((state) => state);
};
const useOption = <K extends keyof T>(key: K) => {
return store((state) => state[key]);
};
const setOptions = (options: Partial<T>) => {
store.setState((state) => ({
...state,
...options,
}));
};
const setOption = <K extends keyof T>(key: K, value: T[K]) => {
store.setState((state) => ({
...state,
[key]: value,
}));
};
return {
store,
useOptions,
useOption,
setOptions,
setOption,
};
};
const merge = <T extends TSettingd>(
persistedState: unknown,
currentState: T
) => {
if ('columns' in currentState) {
return {
...currentState,
...mergeColumns(persistedState, currentState),
};
}
return {
...currentState,
...((persistedState as T) ?? {}),
};
};
const mergeColumns = <T extends { columns: Column[] }>(
persistedState: unknown,
currentState: T
) => {
const currentColumns = currentState.columns;
const persistedColumns = (persistedState as T).columns;
const columns: Column[] = [];
// Add persisted columns in the same order they're currently in
// as long as they haven't been removed.
persistedColumns.forEach((persistedColumn) => {
const column = currentColumns.find((i) => i.name === persistedColumn.name);
if (column) {
const newColumn: Partial<Column> = {};
// We can't use a spread operator or Object.assign to clone the column
// or any accessors are lost and can break translations.
for (const prop of Object.keys(column)) {
const attributes = Object.getOwnPropertyDescriptor(column, prop);
if (!attributes) {
return;
}
Object.defineProperty(newColumn, prop, attributes);
}
newColumn.isVisible = persistedColumn.isVisible;
columns.push(newColumn as Column);
}
});
// Add any columns added to the app in the initial position.
currentColumns.forEach((currentColumn, index) => {
const persistedColumnIndex = persistedColumns.findIndex(
(i) => i.name === currentColumn.name
);
const column = Object.assign({}, currentColumn);
if (persistedColumnIndex === -1) {
columns.splice(index, 0, column);
}
});
return {
...(persistedState as T),
columns,
};
};
+4
View File
@@ -3,11 +3,15 @@ import { useHistory } from 'react-router';
import { create } from 'zustand'; import { create } from 'zustand';
interface PageStore { interface PageStore {
blocklist: number;
events: number; events: number;
queue: number;
} }
const pageStore = create<PageStore>(() => ({ const pageStore = create<PageStore>(() => ({
blocklist: 1,
events: 1, events: 1,
queue: 1,
})); }));
const usePage = (kind: keyof PageStore) => { const usePage = (kind: keyof PageStore) => {
+38 -17
View File
@@ -25,8 +25,10 @@ interface PagedQueryResponse<T> {
records: T[]; records: T[];
} }
const DEFAULT_RECORDS: never[] = [];
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => { const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
const requestOptions = useMemo(() => { const { requestOptions, queryKey } = useMemo(() => {
const { const {
path, path,
page, page,
@@ -40,27 +42,39 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
} = options; } = options;
return { return {
...otherOptions, queryKey: [
path: path,
getQueryPath(path) + queryParams,
getQueryString({ page,
...queryParams, pageSize,
page, sortKey,
pageSize, sortDirection,
sortKey, filters,
sortDirection, ],
filters, requestOptions: {
}), ...otherOptions,
headers: { path:
...options.headers, getQueryPath(path) +
'X-Api-Key': window.Sonarr.apiKey, getQueryString({
...queryParams,
page,
pageSize,
sortKey,
sortDirection,
filters,
}),
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
}, },
}; };
}, [options]); }, [options]);
return useQuery({ const { data, ...query } = useQuery({
...options.queryOptions, ...options.queryOptions,
queryKey: [requestOptions.path], queryKey,
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const response = await fetchJson<PagedQueryResponse<T>, unknown>({ const response = await fetchJson<PagedQueryResponse<T>, unknown>({
...requestOptions, ...requestOptions,
@@ -76,6 +90,13 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
}; };
}, },
}); });
return {
...query,
records: data?.records ?? DEFAULT_RECORDS,
totalRecords: data?.totalRecords ?? 0,
totalPages: data?.totalPages ?? 0,
};
}; };
export default usePagedApiQuery; export default usePagedApiQuery;
+33 -4
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
@@ -6,14 +7,42 @@ import themes from 'Styles/Themes';
function createThemeSelector() { function createThemeSelector() {
return createSelector( return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme, (state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
(theme) => { (theme) => theme
return theme;
}
); );
} }
const useTheme = () => { const useTheme = () => {
return useSelector(createThemeSelector()); const selectedTheme = useSelector(createThemeSelector());
const [resolvedTheme, setResolvedTheme] = useState(selectedTheme);
useEffect(() => {
if (selectedTheme !== 'auto') {
setResolvedTheme(selectedTheme);
return;
}
const applySystemTheme = () => {
setResolvedTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
);
};
applySystemTheme();
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', applySystemTheme);
return () => {
window
.matchMedia('(prefers-color-scheme: dark)')
.removeEventListener('change', applySystemTheme);
};
}, [selectedTheme]);
return resolvedTheme;
}; };
export default useTheme; export default useTheme;
-54
View File
@@ -1,6 +1,5 @@
import { create, type StateCreator } from 'zustand'; import { create, type StateCreator } from 'zustand';
import { persist, type PersistOptions } from 'zustand/middleware'; import { persist, type PersistOptions } from 'zustand/middleware';
import Column from 'Components/Table/Column';
export const createPersist = <T>( export const createPersist = <T>(
name: string, name: string,
@@ -19,56 +18,3 @@ export const createPersist = <T>(
}) })
); );
}; };
export const mergeColumns = <T extends { columns: Column[] }>(
persistedState: unknown,
currentState: T
) => {
const currentColumns = currentState.columns;
const persistedColumns = (persistedState as T).columns;
const columns: Column[] = [];
// Add persisted columns in the same order they're currently in
// as long as they haven't been removed.
persistedColumns.forEach((persistedColumn) => {
const column = currentColumns.find((i) => i.name === persistedColumn.name);
if (column) {
const newColumn: Partial<Column> = {};
// We can't use a spread operator or Object.assign to clone the column
// or any accessors are lost and can break translations.
for (const prop of Object.keys(column)) {
const attributes = Object.getOwnPropertyDescriptor(column, prop);
if (!attributes) {
return;
}
Object.defineProperty(newColumn, prop, attributes);
}
newColumn.isVisible = persistedColumn.isVisible;
columns.push(newColumn as Column);
}
});
// Add any columns added to the app in the initial position.
currentColumns.forEach((currentColumn, index) => {
const persistedColumnIndex = persistedColumns.findIndex(
(i) => i.name === currentColumn.name
);
const column = Object.assign({}, currentColumn);
if (persistedColumnIndex === -1) {
columns.splice(index, 0, column);
}
});
return {
...(persistedState as T),
columns,
};
};
@@ -1,10 +1,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
import Label from 'Components/Label'; import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props'; import { kinds, sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
function getEpisodeCountKind( function getEpisodeCountKind(
monitored: boolean, monitored: boolean,
@@ -44,9 +41,7 @@ function SeasonProgressLabel({
episodeCount, episodeCount,
episodeFileCount, episodeFileCount,
}: SeasonProgressLabelProps) { }: SeasonProgressLabelProps) {
const queueDetails: SeriesQueueDetails = useSelector( const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles; const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const text = newDownloads const text = newDownloads
@@ -57,11 +57,12 @@
.title { .title {
overflow: auto; overflow: auto;
max-height: calc(3 * 50px); max-height: calc(3 * 60px);
text-wrap: balance; text-wrap: balance;
font-weight: 300; font-weight: 300;
font-size: 50px; font-size: 50px;
line-height: 50px; line-height: 60px;
-webkit-line-clamp: 3;
line-clamp: 3; line-clamp: 3;
} }
@@ -82,6 +83,7 @@
.alternateTitlesIconContainer { .alternateTitlesIconContainer {
align-self: flex-end; align-self: flex-end;
margin-bottom: 10px;
margin-left: 20px; margin-left: 20px;
} }
+386 -380
View File
@@ -2,6 +2,7 @@ import moment from 'moment';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
@@ -47,10 +48,6 @@ import {
clearEpisodeFiles, clearEpisodeFiles,
fetchEpisodeFiles, fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions'; } from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions'; import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
@@ -380,7 +377,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const populate = useCallback(() => { const populate = useCallback(() => {
dispatch(fetchEpisodes({ seriesId })); dispatch(fetchEpisodes({ seriesId }));
dispatch(fetchEpisodeFiles({ seriesId })); dispatch(fetchEpisodeFiles({ seriesId }));
dispatch(fetchQueueDetails({ seriesId }));
}, [seriesId, dispatch]); }, [seriesId, dispatch]);
useEffect(() => { useEffect(() => {
@@ -394,7 +390,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
unregisterPagePopulator(populate); unregisterPagePopulator(populate);
dispatch(clearEpisodes()); dispatch(clearEpisodes());
dispatch(clearEpisodeFiles()); dispatch(clearEpisodeFiles());
dispatch(clearQueueDetails());
}; };
}, [populate, dispatch]); }, [populate, dispatch]);
@@ -466,424 +461,435 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated; const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
return ( return (
<PageContent title={title}> <QueueDetailsProvider seriesId={seriesId}>
<PageToolbar> <PageContent title={title}>
<PageToolbarSection> <PageToolbar>
<PageToolbarButton <PageToolbarSection>
label={translate('RefreshAndScan')} <PageToolbarButton
iconName={icons.REFRESH} label={translate('RefreshAndScan')}
spinningName={icons.REFRESH} iconName={icons.REFRESH}
title={translate('RefreshAndScanTooltip')} spinningName={icons.REFRESH}
isSpinning={isRefreshing} title={translate('RefreshAndScanTooltip')}
onPress={handleRefreshPress} isSpinning={isRefreshing}
/> onPress={handleRefreshPress}
<PageToolbarButton
label={translate('SearchMonitored')}
iconName={icons.SEARCH}
isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
isSpinning={isSearching}
title={
hasMonitoredEpisodes
? undefined
: translate('NoMonitoredEpisodes')
}
onPress={handleSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('PreviewRename')}
iconName={icons.ORGANIZE}
isDisabled={!hasEpisodeFiles}
onPress={handleOrganizePress}
/>
<PageToolbarButton
label={translate('ManageEpisodes')}
iconName={icons.EPISODE_FILE}
onPress={handleManageEpisodesPress}
/>
<PageToolbarButton
label={translate('History')}
iconName={icons.HISTORY}
isDisabled={!hasEpisodes}
onPress={handleSeriesHistoryPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('EpisodeMonitoring')}
iconName={icons.MONITORED}
onPress={handleMonitorOptionsPress}
/>
<PageToolbarButton
label={translate('Edit')}
iconName={icons.EDIT}
onPress={handleEditSeriesPress}
/>
<PageToolbarButton
label={translate('Delete')}
iconName={icons.DELETE}
onPress={handleDeleteSeriesPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={
expandedState.allExpanded
? translate('CollapseAll')
: translate('ExpandAll')
}
iconName={expandIcon}
onPress={handleExpandAllPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<SeriesPoster
className={styles.poster}
images={images}
size={500}
lazy={false}
/> />
<div className={styles.info}> <PageToolbarButton
<div className={styles.titleRow}> label={translate('SearchMonitored')}
<div className={styles.titleContainer}> iconName={icons.SEARCH}
<div className={styles.toggleMonitoredContainer}> isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
<MonitorToggleButton isSpinning={isSearching}
className={styles.monitorToggleButton} title={
monitored={monitored} hasMonitoredEpisodes
isSaving={isSaving} ? undefined
size={40} : translate('NoMonitoredEpisodes')
onPress={handleMonitorTogglePress} }
/> onPress={handleSearchPress}
</div> />
<div className={styles.title}>{title}</div> <PageToolbarSeparator />
{alternateTitles.length ? ( <PageToolbarButton
<div className={styles.alternateTitlesIconContainer}> label={translate('PreviewRename')}
<Popover iconName={icons.ORGANIZE}
anchor={ isDisabled={!hasEpisodeFiles}
<Icon name={icons.ALTERNATE_TITLES} size={20} /> onPress={handleOrganizePress}
} />
title={translate('AlternateTitles')}
body={ <PageToolbarButton
<SeriesAlternateTitles label={translate('ManageEpisodes')}
alternateTitles={alternateTitles} iconName={icons.EPISODE_FILE}
/> onPress={handleManageEpisodesPress}
} />
position={tooltipPositions.BOTTOM}
<PageToolbarButton
label={translate('History')}
iconName={icons.HISTORY}
isDisabled={!hasEpisodes}
onPress={handleSeriesHistoryPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('EpisodeMonitoring')}
iconName={icons.MONITORED}
onPress={handleMonitorOptionsPress}
/>
<PageToolbarButton
label={translate('Edit')}
iconName={icons.EDIT}
onPress={handleEditSeriesPress}
/>
<PageToolbarButton
label={translate('Delete')}
iconName={icons.DELETE}
onPress={handleDeleteSeriesPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={
expandedState.allExpanded
? translate('CollapseAll')
: translate('ExpandAll')
}
iconName={expandIcon}
onPress={handleExpandAllPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<SeriesPoster
className={styles.poster}
images={images}
size={500}
lazy={false}
/>
<div className={styles.info}>
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
size={40}
onPress={handleMonitorTogglePress}
/> />
</div> </div>
) : null}
</div>
<div className={styles.seriesNavigationButtons}> <div className={styles.title}>{title}</div>
{previousSeries ? (
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: previousSeries.title,
})}
to={`/series/${previousSeries.titleSlug}`}
/>
) : null}
{nextSeries ? ( {alternateTitles.length ? (
<IconButton <div className={styles.alternateTitlesIconContainer}>
className={styles.seriesNavigationButton} <Popover
name={icons.ARROW_RIGHT} anchor={
size={30} <Icon name={icons.ALTERNATE_TITLES} size={20} />
title={translate('SeriesDetailsGoTo', { }
title: nextSeries.title, title={translate('AlternateTitles')}
})} body={
to={`/series/${nextSeries.titleSlug}`} <SeriesAlternateTitles
/> alternateTitles={alternateTitles}
) : null} />
</div> }
</div> position={tooltipPositions.BOTTOM}
/>
<div className={styles.details}>
<div>
{runtime ? (
<span className={styles.runtime}>
{translate('SeriesDetailsRuntime', { runtime })}
</span>
) : null}
{ratings.value ? (
<HeartRating
rating={ratings.value}
votes={ratings.votes}
iconSize={20}
/>
) : null}
<SeriesGenres className={styles.genres} genres={genres} />
<span>{runningYears}</span>
</div>
</div>
<div>
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div>
<Icon name={icons.FOLDER} size={17} />
<span className={styles.path}>{path}</span>
</div>
</Label>
<Tooltip
anchor={
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div>
<Icon name={icons.DRIVE} size={17} />
<span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)}
</span>
</div> </div>
</Label> ) : null}
}
tooltip={<span>{episodeFilesCountMessage}</span>}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
<Label
className={styles.detailsLabel}
title={translate('QualityProfile')}
size={sizes.LARGE}
>
<div>
<Icon name={icons.PROFILE} size={17} />
<span className={styles.qualityProfileName}>
<QualityProfileName qualityProfileId={qualityProfileId} />
</span>
</div> </div>
</Label>
<Label className={styles.detailsLabel} size={sizes.LARGE}> <div className={styles.seriesNavigationButtons}>
<div> {previousSeries ? (
<Icon <IconButton
name={monitored ? icons.MONITORED : icons.UNMONITORED} className={styles.seriesNavigationButton}
size={17} name={icons.ARROW_LEFT}
/> size={30}
<span className={styles.qualityProfileName}> title={translate('SeriesDetailsGoTo', {
{monitored title: previousSeries.title,
? translate('Monitored') })}
: translate('Unmonitored')} to={`/series/${previousSeries.titleSlug}`}
</span> />
) : null}
{nextSeries ? (
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: nextSeries.title,
})}
to={`/series/${nextSeries.titleSlug}`}
/>
) : null}
</div> </div>
</Label> </div>
<Label <div className={styles.details}>
className={styles.detailsLabel}
title={statusDetails.message}
size={sizes.LARGE}
kind={status === 'deleted' ? kinds.INVERSE : undefined}
>
<div> <div>
<Icon name={statusDetails.icon} size={17} /> {runtime ? (
<span className={styles.statusName}> <span className={styles.runtime}>
{statusDetails.title} {translate('SeriesDetailsRuntime', { runtime })}
</span>
</div>
</Label>
{originalLanguage?.name ? (
<Label
className={styles.detailsLabel}
title={translate('OriginalLanguage')}
size={sizes.LARGE}
>
<div>
<Icon name={icons.LANGUAGE} size={17} />
<span className={styles.originalLanguageName}>
{originalLanguage.name}
</span> </span>
</div> ) : null}
</Label>
) : null}
{network ? ( {ratings.value ? (
<Label <HeartRating
className={styles.detailsLabel} rating={ratings.value}
title={translate('Network')} votes={ratings.votes}
size={sizes.LARGE} iconSize={20}
> />
) : null}
<SeriesGenres className={styles.genres} genres={genres} />
<span>{runningYears}</span>
</div>
</div>
<div>
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div> <div>
<Icon name={icons.NETWORK} size={17} /> <Icon name={icons.FOLDER} size={17} />
<span className={styles.network}>{network}</span> <span className={styles.path}>{path}</span>
</div> </div>
</Label> </Label>
) : null}
<Tooltip
anchor={
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div>
<Icon name={icons.EXTERNAL_LINK} size={17} />
<span className={styles.links}>
{translate('Links')}
</span>
</div>
</Label>
}
tooltip={
<SeriesDetailsLinks
tvdbId={tvdbId}
tvMazeId={tvMazeId}
imdbId={imdbId}
tmdbId={tmdbId}
/>
}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
{tags.length ? (
<Tooltip <Tooltip
anchor={ anchor={
<Label className={styles.detailsLabel} size={sizes.LARGE}> <Label className={styles.detailsLabel} size={sizes.LARGE}>
<Icon name={icons.TAGS} size={17} /> <div>
<Icon name={icons.DRIVE} size={17} />
<span className={styles.tags}>{translate('Tags')}</span> <span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)}
</span>
</div>
</Label> </Label>
} }
tooltip={<SeriesTags seriesId={seriesId} />} tooltip={<span>{episodeFilesCountMessage}</span>}
kind={kinds.INVERSE} kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM} position={tooltipPositions.BOTTOM}
/> />
) : null}
<SeriesProgressLabel <Label
className={styles.seriesProgressLabel} className={styles.detailsLabel}
seriesId={seriesId} title={translate('QualityProfile')}
monitored={monitored} size={sizes.LARGE}
episodeCount={episodeCount} >
episodeFileCount={episodeFileCount} <div>
/> <Icon name={icons.PROFILE} size={17} />
<span className={styles.qualityProfileName}>
<QualityProfileName
qualityProfileId={qualityProfileId}
/>
</span>
</div>
</Label>
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
size={17}
/>
<span className={styles.qualityProfileName}>
{monitored
? translate('Monitored')
: translate('Unmonitored')}
</span>
</div>
</Label>
<Label
className={styles.detailsLabel}
title={statusDetails.message}
size={sizes.LARGE}
kind={status === 'deleted' ? kinds.INVERSE : undefined}
>
<div>
<Icon name={statusDetails.icon} size={17} />
<span className={styles.statusName}>
{statusDetails.title}
</span>
</div>
</Label>
{originalLanguage?.name ? (
<Label
className={styles.detailsLabel}
title={translate('OriginalLanguage')}
size={sizes.LARGE}
>
<div>
<Icon name={icons.LANGUAGE} size={17} />
<span className={styles.originalLanguageName}>
{originalLanguage.name}
</span>
</div>
</Label>
) : null}
{network ? (
<Label
className={styles.detailsLabel}
title={translate('Network')}
size={sizes.LARGE}
>
<div>
<Icon name={icons.NETWORK} size={17} />
<span className={styles.network}>{network}</span>
</div>
</Label>
) : null}
<Tooltip
anchor={
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div>
<Icon name={icons.EXTERNAL_LINK} size={17} />
<span className={styles.links}>
{translate('Links')}
</span>
</div>
</Label>
}
tooltip={
<SeriesDetailsLinks
tvdbId={tvdbId}
tvMazeId={tvMazeId}
imdbId={imdbId}
tmdbId={tmdbId}
/>
}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
{tags.length ? (
<Tooltip
anchor={
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon name={icons.TAGS} size={17} />
<span className={styles.tags}>
{translate('Tags')}
</span>
</Label>
}
tooltip={<SeriesTags seriesId={seriesId} />}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
) : null}
<SeriesProgressLabel
className={styles.seriesProgressLabel}
seriesId={seriesId}
monitored={monitored}
episodeCount={episodeCount}
episodeFileCount={episodeFileCount}
/>
</div>
<div className={styles.overview}>{overview}</div>
<MetadataAttribution />
</div> </div>
<div className={styles.overview}>{overview}</div>
<MetadataAttribution />
</div> </div>
</div> </div>
</div>
<div className={styles.contentContainer}> <div className={styles.contentContainer}>
{!isPopulated && !episodesError && !episodeFilesError ? ( {!isPopulated && !episodesError && !episodeFilesError ? (
<LoadingIndicator /> <LoadingIndicator />
) : null} ) : null}
{!isFetching && episodesError ? ( {!isFetching && episodesError ? (
<Alert kind={kinds.DANGER}>{translate('EpisodesLoadError')}</Alert> <Alert kind={kinds.DANGER}>
) : null} {translate('EpisodesLoadError')}
</Alert>
) : null}
{!isFetching && episodeFilesError ? ( {!isFetching && episodeFilesError ? (
<Alert kind={kinds.DANGER}> <Alert kind={kinds.DANGER}>
{translate('EpisodeFilesLoadError')} {translate('EpisodeFilesLoadError')}
</Alert> </Alert>
) : null} ) : null}
{isPopulated && !!seasons.length ? ( {isPopulated && !!seasons.length ? (
<div> <div>
{seasons {seasons
.slice(0) .slice(0)
.reverse() .reverse()
.map((season) => { .map((season) => {
return ( return (
<SeriesDetailsSeason <SeriesDetailsSeason
key={season.seasonNumber} key={season.seasonNumber}
seriesId={seriesId} seriesId={seriesId}
{...season} {...season}
isExpanded={expandedState.seasons[season.seasonNumber]} isExpanded={expandedState.seasons[season.seasonNumber]}
onExpandPress={handleExpandPress} onExpandPress={handleExpandPress}
/> />
); );
})} })}
</div> </div>
) : null} ) : null}
{isPopulated && !seasons.length ? ( {isPopulated && !seasons.length ? (
<Alert kind={kinds.WARNING}> <Alert kind={kinds.WARNING}>
{translate('NoEpisodeInformation')} {translate('NoEpisodeInformation')}
</Alert> </Alert>
) : null} ) : null}
</div> </div>
<OrganizePreviewModal <OrganizePreviewModal
isOpen={isOrganizeModalOpen} isOpen={isOrganizeModalOpen}
seriesId={seriesId} seriesId={seriesId}
onModalClose={handleOrganizeModalClose} onModalClose={handleOrganizeModalClose}
/> />
<InteractiveImportModal <InteractiveImportModal
isOpen={isManageEpisodesOpen} isOpen={isManageEpisodesOpen}
seriesId={seriesId} seriesId={seriesId}
title={title} title={title}
folder={path} folder={path}
initialSortKey="relativePath" initialSortKey="relativePath"
initialSortDirection={sortDirections.DESCENDING} initialSortDirection={sortDirections.DESCENDING}
showSeries={false} showSeries={false}
allowSeriesChange={false} allowSeriesChange={false}
showDelete={true} showDelete={true}
showImportMode={false} showImportMode={false}
modalTitle={translate('ManageEpisodes')} modalTitle={translate('ManageEpisodes')}
onModalClose={handleManageEpisodesModalClose} onModalClose={handleManageEpisodesModalClose}
/> />
<SeriesHistoryModal <SeriesHistoryModal
isOpen={isSeriesHistoryModalOpen} isOpen={isSeriesHistoryModalOpen}
seriesId={seriesId} seriesId={seriesId}
onModalClose={handleSeriesHistoryModalClose} onModalClose={handleSeriesHistoryModalClose}
/> />
<EditSeriesModal <EditSeriesModal
isOpen={isEditSeriesModalOpen} isOpen={isEditSeriesModalOpen}
seriesId={seriesId} seriesId={seriesId}
onModalClose={handleEditSeriesModalClose} onModalClose={handleEditSeriesModalClose}
onDeleteSeriesPress={handleDeleteSeriesPress} onDeleteSeriesPress={handleDeleteSeriesPress}
/> />
<DeleteSeriesModal <DeleteSeriesModal
isOpen={isDeleteSeriesModalOpen} isOpen={isDeleteSeriesModalOpen}
seriesId={seriesId} seriesId={seriesId}
onModalClose={handleDeleteSeriesModalClose} onModalClose={handleDeleteSeriesModalClose}
/> />
<MonitoringOptionsModal <MonitoringOptionsModal
isOpen={isMonitorOptionsModalOpen} isOpen={isMonitorOptionsModalOpen}
seriesId={seriesId} seriesId={seriesId}
onModalClose={handleMonitorOptionsClose} onModalClose={handleMonitorOptionsClose}
/> />
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
</QueueDetailsProvider>
); );
} }
@@ -1,10 +1,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
import Label from 'Components/Label'; import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props'; import { kinds, sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
function getEpisodeCountKind( function getEpisodeCountKind(
monitored: boolean, monitored: boolean,
@@ -42,9 +39,7 @@ function SeriesProgressLabel({
episodeCount, episodeCount,
episodeFileCount, episodeFileCount,
}: SeriesProgressLabelProps) { }: SeriesProgressLabelProps) {
const queueDetails: SeriesQueueDetails = useSelector( const queueDetails = useQueueDetailsForSeries(seriesId);
createSeriesQueueItemsDetailsSelector(seriesId)
);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles; const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const text = newDownloads const text = newDownloads
@@ -144,7 +144,10 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
<div className={styles.poster}> <div className={styles.poster}>
<div className={styles.posterContainer}> <div className={styles.posterContainer}>
{isSelectMode ? ( {isSelectMode ? (
<SeriesIndexPosterSelect seriesId={seriesId} /> <SeriesIndexPosterSelect
seriesId={seriesId}
titleSlug={titleSlug}
/>
) : null} ) : null}
{status === 'ended' ? ( {status === 'ended' ? (
@@ -1,7 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { SyntheticEvent, useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useSelect } from 'App/SelectContext';
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames'; import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import Label from 'Components/Label'; import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
@@ -123,31 +122,8 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
setIsDeleteSeriesModalOpen(false); setIsDeleteSeriesModalOpen(false);
}, [setIsDeleteSeriesModalOpen]); }, [setIsDeleteSeriesModalOpen]);
const [selectState, selectDispatch] = useSelect();
const onSelectPress = useCallback(
(event: SyntheticEvent<HTMLElement, MouseEvent>) => {
if (event.nativeEvent.ctrlKey || event.nativeEvent.metaKey) {
window.open(`/series/${titleSlug}`, '_blank');
return;
}
const shiftKey = event.nativeEvent.shiftKey;
selectDispatch({
type: 'toggleSelected',
id: seriesId,
isSelected: !selectState.selectedState[seriesId],
shiftKey,
});
},
[seriesId, selectState.selectedState, selectDispatch, titleSlug]
);
const link = `/series/${titleSlug}`; const link = `/series/${titleSlug}`;
const linkProps = isSelectMode ? { onPress: onSelectPress } : { to: link };
const elementStyle = { const elementStyle = {
width: `${posterWidth}px`, width: `${posterWidth}px`,
height: `${posterHeight}px`, height: `${posterHeight}px`,
@@ -156,7 +132,9 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
return ( return (
<div className={styles.content}> <div className={styles.content}>
<div className={styles.posterContainer} title={title}> <div className={styles.posterContainer} title={title}>
{isSelectMode ? <SeriesIndexPosterSelect seriesId={seriesId} /> : null} {isSelectMode ? (
<SeriesIndexPosterSelect seriesId={seriesId} titleSlug={titleSlug} />
) : null}
<Label className={styles.controls}> <Label className={styles.controls}>
<SpinnerIconButton <SpinnerIconButton
@@ -199,7 +177,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
/> />
) : null} ) : null}
<Link className={styles.link} style={elementStyle} {...linkProps}> <Link className={styles.link} style={elementStyle} to={link}>
<SeriesPoster <SeriesPoster
style={elementStyle} style={elementStyle}
images={images} images={images}
@@ -1,10 +1,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
import ProgressBar from 'Components/ProgressBar'; import ProgressBar from 'Components/ProgressBar';
import { sizes } from 'Helpers/Props'; import { sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
import { SeriesStatus } from 'Series/Series'; import { SeriesStatus } from 'Series/Series';
import getProgressBarKind from 'Utilities/Series/getProgressBarKind'; import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
@@ -37,9 +34,7 @@ function SeriesIndexProgressBar(props: SeriesIndexProgressBarProps) {
isStandalone, isStandalone,
} = props; } = props;
const queueDetails: SeriesQueueDetails = useSelector( const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles; const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const progress = episodeCount ? (episodeFileCount / episodeCount) * 100 : 100; const progress = episodeCount ? (episodeFileCount / episodeCount) * 100 : 100;
@@ -3,8 +3,8 @@
top: 0; top: 0;
left: 0; left: 0;
z-index: 3; z-index: 3;
width: 36px; width: 100%;
height: 36px; height: 100%;
} }
.checkContainer { .checkContainer {
@@ -7,15 +7,23 @@ import styles from './SeriesIndexPosterSelect.css';
interface SeriesIndexPosterSelectProps { interface SeriesIndexPosterSelectProps {
seriesId: number; seriesId: number;
titleSlug: string;
} }
function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) { function SeriesIndexPosterSelect({
const { seriesId } = props; seriesId,
titleSlug,
}: SeriesIndexPosterSelectProps) {
const [selectState, selectDispatch] = useSelect(); const [selectState, selectDispatch] = useSelect();
const isSelected = selectState.selectedState[seriesId]; const isSelected = selectState.selectedState[seriesId];
const onSelectPress = useCallback( const onSelectPress = useCallback(
(event: SyntheticEvent<HTMLElement, PointerEvent>) => { (event: SyntheticEvent<HTMLElement, PointerEvent>) => {
if (event.nativeEvent.ctrlKey || event.nativeEvent.metaKey) {
window.open(`${window.Sonarr.urlBase}/series/${titleSlug}`, '_blank');
return;
}
const shiftKey = event.nativeEvent.shiftKey; const shiftKey = event.nativeEvent.shiftKey;
selectDispatch({ selectDispatch({
@@ -25,7 +33,7 @@ function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) {
shiftKey, shiftKey,
}); });
}, },
[seriesId, isSelected, selectDispatch] [seriesId, titleSlug, isSelected, selectDispatch]
); );
return ( return (
+133 -130
View File
@@ -6,6 +6,7 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
import { SelectProvider } from 'App/SelectContext'; import { SelectProvider } from 'App/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState'; import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState'; import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState';
@@ -26,7 +27,6 @@ import { DESCENDING } from 'Helpers/Props/sortDirections';
import ParseToolbarButton from 'Parse/ParseToolbarButton'; import ParseToolbarButton from 'Parse/ParseToolbarButton';
import NoSeries from 'Series/NoSeries'; import NoSeries from 'Series/NoSeries';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchSeries } from 'Store/Actions/seriesActions'; import { fetchSeries } from 'Store/Actions/seriesActions';
import { import {
setSeriesFilter, setSeriesFilter,
@@ -104,7 +104,6 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
useEffect(() => { useEffect(() => {
dispatch(fetchSeries()); dispatch(fetchSeries());
dispatch(fetchQueueDetails({ all: true }));
}, [dispatch]); }, [dispatch]);
const onRssSyncPress = useCallback(() => { const onRssSyncPress = useCallback(() => {
@@ -217,155 +216,159 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
const hasNoSeries = !totalItems; const hasNoSeries = !totalItems;
return ( return (
<SelectProvider items={items}> <QueueDetailsProvider all={true}>
<PageContent> <SelectProvider items={items}>
<PageToolbar> <PageContent>
<PageToolbarSection> <PageToolbar>
<SeriesIndexRefreshSeriesButton <PageToolbarSection>
isSelectMode={isSelectMode} <SeriesIndexRefreshSeriesButton
selectedFilterKey={selectedFilterKey} isSelectMode={isSelectMode}
/> selectedFilterKey={selectedFilterKey}
/>
<PageToolbarButton <PageToolbarButton
label={translate('RssSync')} label={translate('RssSync')}
iconName={icons.RSS} iconName={icons.RSS}
isSpinning={isRssSyncExecuting} isSpinning={isRssSyncExecuting}
isDisabled={hasNoSeries} isDisabled={hasNoSeries}
onPress={onRssSyncPress} onPress={onRssSyncPress}
/> />
<PageToolbarSeparator /> <PageToolbarSeparator />
<SeriesIndexSelectModeButton <SeriesIndexSelectModeButton
label={ label={
isSelectMode isSelectMode
? translate('StopSelecting') ? translate('StopSelecting')
: translate('SelectSeries') : translate('SelectSeries')
} }
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK} iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
isSelectMode={isSelectMode} isSelectMode={isSelectMode}
overflowComponent={SeriesIndexSelectModeMenuItem} overflowComponent={SeriesIndexSelectModeMenuItem}
onPress={onSelectModePress} onPress={onSelectModePress}
/> />
<SeriesIndexSelectAllButton <SeriesIndexSelectAllButton
label="SelectAll" label="SelectAll"
isSelectMode={isSelectMode} isSelectMode={isSelectMode}
overflowComponent={SeriesIndexSelectAllMenuItem} overflowComponent={SeriesIndexSelectAllMenuItem}
/> />
<PageToolbarSeparator /> <PageToolbarSeparator />
<ParseToolbarButton /> <ParseToolbarButton />
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection <PageToolbarSection
alignContent={align.RIGHT} alignContent={align.RIGHT}
collapseButtons={false} collapseButtons={false}
> >
{view === 'table' ? ( {view === 'table' ? (
<TableOptionsModalWrapper <TableOptionsModalWrapper
columns={columns} columns={columns}
optionsComponent={SeriesIndexTableOptions} optionsComponent={SeriesIndexTableOptions}
onTableOptionChange={onTableOptionChange} onTableOptionChange={onTableOptionChange}
> >
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
) : (
<PageToolbarButton <PageToolbarButton
label={translate('Options')} label={translate('Options')}
iconName={icons.TABLE} iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
isDisabled={hasNoSeries}
onPress={onOptionsPress}
/> />
</TableOptionsModalWrapper> )}
) : (
<PageToolbarButton <PageToolbarSeparator />
label={translate('Options')}
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW} <SeriesIndexViewMenu
view={view}
isDisabled={hasNoSeries} isDisabled={hasNoSeries}
onPress={onOptionsPress} onViewSelect={onViewSelect}
/> />
)}
<PageToolbarSeparator /> <SeriesIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoSeries}
onSortSelect={onSortSelect}
/>
<SeriesIndexViewMenu <SeriesIndexFilterMenu
view={view} selectedFilterKey={selectedFilterKey}
isDisabled={hasNoSeries} filters={filters}
onViewSelect={onViewSelect} customFilters={customFilters}
/> isDisabled={hasNoSeries}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
ref={scrollerRef}
className={styles.contentBody}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
innerClassName={styles[`${view}InnerContentBody`]}
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
<SeriesIndexSortMenu {!isFetching && !!error ? (
sortKey={sortKey} <Alert kind={kinds.DANGER}>
sortDirection={sortDirection} {translate('SeriesLoadError')}
isDisabled={hasNoSeries} </Alert>
onSortSelect={onSortSelect} ) : null}
/>
<SeriesIndexFilterMenu {isLoaded ? (
selectedFilterKey={selectedFilterKey} <div className={styles.contentBodyContainer}>
filters={filters} <ViewComponent
customFilters={customFilters} scrollerRef={scrollerRef}
isDisabled={hasNoSeries} items={items}
onFilterSelect={onFilterSelect} sortKey={sortKey}
/> sortDirection={sortDirection}
</PageToolbarSection> jumpToCharacter={jumpToCharacter}
</PageToolbar> isSelectMode={isSelectMode}
<div className={styles.pageContentBodyWrapper}> isSmallScreen={isSmallScreen}
<PageContentBody />
ref={scrollerRef}
className={styles.contentBody}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
innerClassName={styles[`${view}InnerContentBody`]}
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? ( <SeriesIndexFooter />
<Alert kind={kinds.DANGER}>{translate('SeriesLoadError')}</Alert> </div>
) : null}
{!error && isPopulated && !items.length ? (
<NoSeries totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar
items={jumpBarItems}
onItemPress={onJumpBarItemPress}
/>
) : null} ) : null}
</div>
{isLoaded ? ( {isSelectMode ? <SeriesIndexSelectFooter /> : null}
<div className={styles.contentBodyContainer}>
<ViewComponent
scrollerRef={scrollerRef}
items={items}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSelectMode={isSelectMode}
isSmallScreen={isSmallScreen}
/>
<SeriesIndexFooter /> {view === 'posters' ? (
</div> <SeriesIndexPosterOptionsModal
) : null} isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
{!error && isPopulated && !items.length ? (
<NoSeries totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar
items={jumpBarItems}
onItemPress={onJumpBarItemPress}
/> />
) : null} ) : null}
</div> {view === 'overview' ? (
<SeriesIndexOverviewOptionsModal
{isSelectMode ? <SeriesIndexSelectFooter /> : null} isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
{view === 'posters' ? ( />
<SeriesIndexPosterOptionsModal ) : null}
isOpen={isOptionsModalOpen} </PageContent>
onModalClose={onOptionsModalClose} </SelectProvider>
/> </QueueDetailsProvider>
) : null}
{view === 'overview' ? (
<SeriesIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
</PageContent>
</SelectProvider>
); );
}, 'seriesIndex'); }, 'seriesIndex');
@@ -1,46 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
export interface SeriesQueueDetails {
count: number;
episodesWithFiles: number;
}
function createSeriesQueueDetailsSelector(
seriesId: number,
seasonNumber?: number
) {
return createSelector(
(state: AppState) => state.queue.details.items,
(queueItems) => {
return queueItems.reduce(
(acc: SeriesQueueDetails, item) => {
if (
item.trackedDownloadState === 'imported' ||
item.seriesId !== seriesId
) {
return acc;
}
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
return acc;
}
acc.count++;
if (item.episodeHasFile) {
acc.episodesWithFiles++;
}
return acc;
},
{
count: 0,
episodesWithFiles: 0,
}
);
}
);
}
export default createSeriesQueueDetailsSelector;
@@ -156,6 +156,7 @@ function GeneralSettings() {
enableSsl={settings.enableSsl} enableSsl={settings.enableSsl}
sslPort={settings.sslPort} sslPort={settings.sslPort}
sslCertPath={settings.sslCertPath} sslCertPath={settings.sslCertPath}
sslKeyPath={settings.sslKeyPath}
sslCertPassword={settings.sslCertPassword} sslCertPassword={settings.sslCertPassword}
launchBrowser={settings.launchBrowser} launchBrowser={settings.launchBrowser}
onInputChange={handleInputChange} onInputChange={handleInputChange}
+43 -28
View File
@@ -19,6 +19,7 @@ interface HostSettingsProps {
applicationUrl: PendingSection<General>['applicationUrl']; applicationUrl: PendingSection<General>['applicationUrl'];
enableSsl: PendingSection<General>['enableSsl']; enableSsl: PendingSection<General>['enableSsl'];
sslPort: PendingSection<General>['sslPort']; sslPort: PendingSection<General>['sslPort'];
sslKeyPath: PendingSection<General>['sslKeyPath'];
sslCertPath: PendingSection<General>['sslCertPath']; sslCertPath: PendingSection<General>['sslCertPath'];
sslCertPassword: PendingSection<General>['sslCertPassword']; sslCertPassword: PendingSection<General>['sslCertPassword'];
launchBrowser: PendingSection<General>['launchBrowser']; launchBrowser: PendingSection<General>['launchBrowser'];
@@ -34,6 +35,7 @@ function HostSettings({
enableSsl, enableSsl,
sslPort, sslPort,
sslCertPath, sslCertPath,
sslKeyPath,
sslCertPassword, sslCertPassword,
launchBrowser, launchBrowser,
onInputChange, onInputChange,
@@ -142,36 +144,49 @@ function HostSettings({
) : null} ) : null}
{enableSsl.value ? ( {enableSsl.value ? (
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}> <>
<FormLabel>{translate('SslCertPath')}</FormLabel> <FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPath')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.TEXT} type={inputTypes.TEXT}
name="sslCertPath" name="sslCertPath"
helpText={translate('SslCertPathHelpText')} helpText={translate('SslCertPathHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')} helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange} onChange={onInputChange}
{...sslCertPath} {...sslCertPath}
/> />
</FormGroup> </FormGroup>
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslKeyPath')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="sslKeyPath"
helpText={translate('SslKeyPathHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslKeyPath}
/>
</FormGroup>
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPassword')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="sslCertPassword"
helpText={translate('SslCertPasswordHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslCertPassword}
/>
</FormGroup>
</>
) : null} ) : null}
{enableSsl.value ? ( {isWindowsService ? null : (
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPassword')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="sslCertPassword"
helpText={translate('SslCertPasswordHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslCertPassword}
/>
</FormGroup>
) : null}
{isWindowsService ? (
<FormGroup size={sizes.MEDIUM}> <FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('OpenBrowserOnStart')}</FormLabel> <FormLabel>{translate('OpenBrowserOnStart')}</FormLabel>
@@ -183,7 +198,7 @@ function HostSettings({
{...launchBrowser} {...launchBrowser}
/> />
</FormGroup> </FormGroup>
) : null} )}
</FieldSet> </FieldSet>
); );
} }
@@ -116,6 +116,27 @@ const fileDateOptions: EnhancedSelectInputValue<string>[] = [
}, },
]; ];
const seasonPackUpgradeOptions: EnhancedSelectInputValue<string>[] = [
{
key: 'all',
get value() {
return translate('All');
},
},
{
key: 'threshold',
get value() {
return translate('Threshold');
},
},
{
key: 'any',
get value() {
return translate('Any');
},
},
];
function MediaManagement() { function MediaManagement() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const showAdvancedSettings = useShowAdvancedSettings(); const showAdvancedSettings = useShowAdvancedSettings();
@@ -379,6 +400,82 @@ function MediaManagement() {
{...settings.userRejectedExtensions} {...settings.userRejectedExtensions}
/> />
</FormGroup> </FormGroup>
{showAdvancedSettings && (
<>
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>
{translate('SeasonPackUpgradeAllowLabel')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="seasonPackUpgrade"
helpText={translate('SeasonPackUpgradeAllowHelpText')}
helpTextWarning={
settings.seasonPackUpgrade.value === 'any'
? translate('SeasonPackUpgradeAllowAnyWarning')
: undefined
}
values={seasonPackUpgradeOptions}
onChange={handleInputChange}
{...settings.seasonPackUpgrade}
/>
</FormGroup>
{settings.seasonPackUpgrade.value === 'threshold' && (
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>
{translate('SeasonPackUpgradeThresholdLabel')}
</FormLabel>
<FormInputGroup
type={inputTypes.FLOAT}
name="seasonPackUpgradeThreshold"
unit="%"
step={0.01}
min={0}
max={100}
helpTexts={[
translate('SeasonPackUpgradeThresholdHelpText'),
translate(
'SeasonPackUpgradeThresholdHelpTextExample',
{
numberEpisodes: 2,
totalEpisodes: 8,
count: Math.ceil((100 * 2) / 8),
}
),
translate(
'SeasonPackUpgradeThresholdHelpTextExample',
{
numberEpisodes: 3,
totalEpisodes: 12,
count: Math.ceil((100 * 3) / 12),
}
),
translate(
'SeasonPackUpgradeThresholdHelpTextExample',
{
numberEpisodes: 6,
totalEpisodes: 24,
count: Math.ceil((100 * 6) / 24),
}
),
]}
onChange={handleInputChange}
{...settings.seasonPackUpgradeThreshold}
/>
</FormGroup>
)}
</>
)}
</FieldSet> </FieldSet>
) : null} ) : null}
@@ -33,6 +33,7 @@ const newReleaseProfile: ReleaseProfile = {
required: [], required: [],
ignored: [], ignored: [],
tags: [], tags: [],
excludedTags: [],
indexerId: 0, indexerId: 0,
}; };
@@ -76,7 +77,8 @@ function EditReleaseProfileModalContent({
const { item, isFetching, isSaving, error, saveError, ...otherProps } = const { item, isFetching, isSaving, error, saveError, ...otherProps } =
useSelector(createReleaseProfileSelector(id)); useSelector(createReleaseProfileSelector(id));
const { name, enabled, required, ignored, tags, indexerId } = item; const { name, enabled, required, ignored, tags, excludedTags, indexerId } =
item;
const dispatch = useDispatch(); const dispatch = useDispatch();
const previousIsSaving = usePrevious(isSaving); const previousIsSaving = usePrevious(isSaving);
@@ -202,6 +204,19 @@ function EditReleaseProfileModalContent({
onChange={handleInputChange} onChange={handleInputChange}
/> />
</FormGroup> </FormGroup>
<FormGroup>
<FormLabel>{translate('ExcludedTags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="excludedTags"
helpText={translate('ReleaseProfileExcludedTagSeriesHelpText')}
kind={kinds.DANGER}
{...excludedTags}
onChange={handleInputChange}
/>
</FormGroup>
</Form> </Form>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
@@ -28,6 +28,7 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
required = [], required = [],
ignored = [], ignored = [],
tags, tags,
excludedTags,
indexerId = 0, indexerId = 0,
tagList, tagList,
indexerList, indexerList,
@@ -92,6 +93,8 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
<TagList tags={tags} tagList={tagList} /> <TagList tags={tags} tagList={tagList} />
<TagList tags={excludedTags} tagList={tagList} kind={kinds.DANGER} />
<div> <div>
{enabled ? null : ( {enabled ? null : (
<Label kind={kinds.DISABLED} outline={true}> <Label kind={kinds.DISABLED} outline={true}>
@@ -61,7 +61,7 @@ export interface TagDetailsModalContentProps {
delayProfileIds: number[]; delayProfileIds: number[];
importListIds: number[]; importListIds: number[];
notificationIds: number[]; notificationIds: number[];
restrictionIds: number[]; releaseProfileIds: number[];
indexerIds: number[]; indexerIds: number[];
downloadClientIds: number[]; downloadClientIds: number[];
autoTagIds: number[]; autoTagIds: number[];
@@ -76,7 +76,7 @@ function TagDetailsModalContent({
delayProfileIds = [], delayProfileIds = [],
importListIds = [], importListIds = [],
notificationIds = [], notificationIds = [],
restrictionIds = [], releaseProfileIds = [],
indexerIds = [], indexerIds = [],
downloadClientIds = [], downloadClientIds = [],
autoTagIds = [], autoTagIds = [],
@@ -109,7 +109,7 @@ function TagDetailsModalContent({
const releaseProfiles = useSelector( const releaseProfiles = useSelector(
createMatchingItemSelector( createMatchingItemSelector(
restrictionIds, releaseProfileIds,
(state: AppState) => state.settings.releaseProfiles.items (state: AppState) => state.settings.releaseProfiles.items
) )
); );
+8 -2
View File
@@ -22,6 +22,7 @@ function Tag({ id, label }: TagProps) {
importListIds = [], importListIds = [],
notificationIds = [], notificationIds = [],
restrictionIds = [], restrictionIds = [],
excludedReleaseProfileIds = [],
indexerIds = [], indexerIds = [],
downloadClientIds = [], downloadClientIds = [],
autoTagIds = [], autoTagIds = [],
@@ -35,12 +36,17 @@ function Tag({ id, label }: TagProps) {
importListIds.length || importListIds.length ||
notificationIds.length || notificationIds.length ||
restrictionIds.length || restrictionIds.length ||
excludedReleaseProfileIds.length ||
indexerIds.length || indexerIds.length ||
downloadClientIds.length || downloadClientIds.length ||
autoTagIds.length || autoTagIds.length ||
seriesIds.length seriesIds.length
); );
const mergedReleaseProfileIds = Array.from(
new Set([...restrictionIds, ...excludedReleaseProfileIds]).values()
);
const handleShowDetailsPress = useCallback(() => { const handleShowDetailsPress = useCallback(() => {
setIsDetailsModalOpen(true); setIsDetailsModalOpen(true);
}, []); }, []);
@@ -95,7 +101,7 @@ function Tag({ id, label }: TagProps) {
<TagInUse <TagInUse
label={translate('ReleaseProfile')} label={translate('ReleaseProfile')}
labelPlural={translate('ReleaseProfiles')} labelPlural={translate('ReleaseProfiles')}
count={restrictionIds.length} count={mergedReleaseProfileIds.length}
/> />
<TagInUse <TagInUse
@@ -126,7 +132,7 @@ function Tag({ id, label }: TagProps) {
delayProfileIds={delayProfileIds} delayProfileIds={delayProfileIds}
importListIds={importListIds} importListIds={importListIds}
notificationIds={notificationIds} notificationIds={notificationIds}
restrictionIds={restrictionIds} releaseProfileIds={mergedReleaseProfileIds}
indexerIds={indexerIds} indexerIds={indexerIds}
downloadClientIds={downloadClientIds} downloadClientIds={downloadClientIds}
autoTagIds={autoTagIds} autoTagIds={autoTagIds}
@@ -5,7 +5,7 @@ import updateSectionState from 'Utilities/State/updateSectionState';
function createSetSettingValueReducer(section) { function createSetSettingValueReducer(section) {
return (state, { payload }) => { return (state, { payload }) => {
if (section === payload.section) { if (section === payload.section) {
const { name, value } = payload; const { name, value, isFloat } = payload;
const newState = getSectionState(state, section); const newState = getSectionState(state, section);
newState.pendingChanges = Object.assign({}, newState.pendingChanges); newState.pendingChanges = Object.assign({}, newState.pendingChanges);
@@ -15,7 +15,12 @@ function createSetSettingValueReducer(section) {
let parsedValue = null; let parsedValue = null;
if (_.isNumber(currentValue) && value != null) { if (_.isNumber(currentValue) && value != null) {
parsedValue = parseInt(value); // Use isFloat property to determine parsing method
if (isFloat) {
parsedValue = parseFloat(value);
} else {
parsedValue = parseInt(value);
}
} else { } else {
parsedValue = value; parsedValue = value;
} }
@@ -1,221 +0,0 @@
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
import translate from 'Utilities/String/translate';
import { set, updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'blocklist';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'date',
sortDirection: sortDirections.DESCENDING,
error: null,
items: [],
isRemoving: false,
columns: [
{
name: 'series.sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isSortable: true,
isVisible: true
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true
},
{
name: 'date',
label: () => translate('Date'),
isSortable: true,
isVisible: true
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: () => translate('All'),
filters: []
}
],
filterBuilderProps: [
{
name: 'seriesIds',
label: () => translate('Series'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.SERIES
},
{
name: 'protocols',
label: () => translate('Protocol'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.PROTOCOL
}
]
};
export const persistState = [
'blocklist.pageSize',
'blocklist.sortKey',
'blocklist.sortDirection',
'blocklist.selectedFilterKey',
'blocklist.columns'
];
//
// Action Types
export const FETCH_BLOCKLIST = 'blocklist/fetchBlocklist';
export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage';
export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort';
export const SET_BLOCKLIST_FILTER = 'blocklist/setBlocklistFilter';
export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption';
export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem';
export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems';
export const CLEAR_BLOCKLIST = 'blocklist/clearBlocklist';
//
// Action Creators
export const fetchBlocklist = createThunk(FETCH_BLOCKLIST);
export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE);
export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT);
export const setBlocklistFilter = createThunk(SET_BLOCKLIST_FILTER);
export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION);
export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM);
export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS);
export const clearBlocklist = createAction(CLEAR_BLOCKLIST);
//
// Action Handlers
export const actionHandlers = handleThunks({
...createServerSideCollectionHandlers(
section,
'/blocklist',
fetchBlocklist,
{
[serverSideCollectionHandlers.FETCH]: FETCH_BLOCKLIST,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLOCKLIST_PAGE,
[serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT,
[serverSideCollectionHandlers.FILTER]: SET_BLOCKLIST_FILTER
}),
[REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'),
[REMOVE_BLOCKLIST_ITEMS]: function(getState, payload, dispatch) {
const {
ids
} = payload;
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section,
id,
isRemoving: true
});
}),
set({ section, isRemoving: true })
]));
const promise = createAjaxRequest({
url: '/blocklist/bulk',
method: 'DELETE',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({ ids })
}).request;
promise.done((data) => {
// Don't use batchActions with thunks
dispatch(fetchBlocklist());
dispatch(set({ section, isRemoving: false }));
});
promise.fail((xhr) => {
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section,
id,
isRemoving: false
});
}),
set({ section, isRemoving: false })
]));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[SET_BLOCKLIST_TABLE_OPTION]: createSetTableOptionReducer(section),
[CLEAR_BLOCKLIST]: createClearReducer(section, {
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
})
}, defaultState, section);
-4
View File
@@ -1,5 +1,4 @@
import * as app from './appActions'; import * as app from './appActions';
import * as blocklist from './blocklistActions';
import * as calendar from './calendarActions'; import * as calendar from './calendarActions';
import * as captcha from './captchaActions'; import * as captcha from './captchaActions';
import * as commands from './commandActions'; import * as commands from './commandActions';
@@ -16,7 +15,6 @@ import * as organizePreview from './organizePreviewActions';
import * as parse from './parseActions'; import * as parse from './parseActions';
import * as paths from './pathActions'; import * as paths from './pathActions';
import * as providerOptions from './providerOptionActions'; import * as providerOptions from './providerOptionActions';
import * as queue from './queueActions';
import * as releases from './releaseActions'; import * as releases from './releaseActions';
import * as rootFolders from './rootFolderActions'; import * as rootFolders from './rootFolderActions';
import * as series from './seriesActions'; import * as series from './seriesActions';
@@ -29,7 +27,6 @@ import * as wanted from './wantedActions';
export default [ export default [
app, app,
blocklist,
calendar, calendar,
captcha, captcha,
commands, commands,
@@ -46,7 +43,6 @@ export default [
parse, parse,
paths, paths,
providerOptions, providerOptions,
queue,
releases, releases,
rootFolders, rootFolders,
series, series,
-562
View File
@@ -1,562 +0,0 @@
import _ from 'lodash';
import React from 'react';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import Icon from 'Components/Icon';
import { filterBuilderTypes, filterBuilderValueTypes, icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
import translate from 'Utilities/String/translate';
import { set, updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'queue';
const status = `${section}.status`;
const details = `${section}.details`;
const paged = `${section}.paged`;
//
// State
export const defaultState = {
options: {
includeUnknownSeriesItems: true
},
removalOptions: {
removalMethod: 'removeFromClient',
blocklistMethod: 'doNotBlocklist'
},
status: {
isFetching: false,
isPopulated: false,
error: null,
item: {}
},
details: {
isFetching: false,
isPopulated: false,
error: null,
items: [],
params: {}
},
paged: {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'timeleft',
sortDirection: sortDirections.ASCENDING,
error: null,
items: [],
isGrabbing: false,
isRemoving: false,
columns: [
{
name: 'status',
columnLabel: () => translate('Status'),
isSortable: true,
isVisible: true,
isModifiable: false
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true
},
{
name: 'episode',
label: () => translate('Episode'),
isSortable: true,
isVisible: true
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isSortable: true,
isVisible: true
},
{
name: 'episodes.airDateUtc',
label: () => translate('EpisodeAirDate'),
isSortable: true,
isVisible: false
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: true,
isVisible: false
},
{
name: 'quality',
label: () => translate('Quality'),
isSortable: true,
isVisible: true
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isVisible: false
},
{
name: 'protocol',
label: () => translate('Protocol'),
isSortable: true,
isVisible: false
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
isSortable: true,
isVisible: false
},
{
name: 'title',
label: () => translate('ReleaseTitle'),
isSortable: true,
isVisible: false
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: false
},
{
name: 'outputPath',
label: () => translate('OutputPath'),
isSortable: false,
isVisible: false
},
{
name: 'estimatedCompletionTime',
label: () => translate('TimeLeft'),
isSortable: true,
isVisible: true
},
{
name: 'added',
label: () => translate('Added'),
isSortable: true,
isVisible: false
},
{
name: 'progress',
label: () => translate('Progress'),
isSortable: true,
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: 'All',
filters: []
}
],
filterBuilderProps: [
{
name: 'seriesIds',
label: () => translate('Series'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.SERIES
},
{
name: 'quality',
label: () => translate('Quality'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUALITY
},
{
name: 'languages',
label: () => translate('Languages'),
type: filterBuilderTypes.CONTAINS,
valueType: filterBuilderValueTypes.LANGUAGE
},
{
name: 'protocol',
label: () => translate('Protocol'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.PROTOCOL
},
{
name: 'status',
label: () => translate('Status'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUEUE_STATUS
}
]
}
};
export const persistState = [
'queue.options',
'queue.removalOptions',
'queue.paged.pageSize',
'queue.paged.sortKey',
'queue.paged.sortDirection',
'queue.paged.columns',
'queue.paged.selectedFilterKey'
];
//
// Helpers
function fetchDataAugmenter(getState, payload, data) {
data.includeUnknownSeriesItems = getState().queue.options.includeUnknownSeriesItems;
}
//
// Actions Types
export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus';
export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails';
export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails';
export const FETCH_QUEUE = 'queue/fetchQueue';
export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage';
export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage';
export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage';
export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage';
export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage';
export const SET_QUEUE_SORT = 'queue/setQueueSort';
export const SET_QUEUE_FILTER = 'queue/setQueueFilter';
export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption';
export const SET_QUEUE_OPTION = 'queue/setQueueOption';
export const SET_QUEUE_REMOVAL_OPTION = 'queue/setQueueRemoveOption';
export const CLEAR_QUEUE = 'queue/clearQueue';
export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem';
export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems';
export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem';
export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems';
//
// Action Creators
export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS);
export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS);
export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS);
export const fetchQueue = createThunk(FETCH_QUEUE);
export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE);
export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE);
export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE);
export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE);
export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE);
export const setQueueSort = createThunk(SET_QUEUE_SORT);
export const setQueueFilter = createThunk(SET_QUEUE_FILTER);
export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION);
export const setQueueOption = createAction(SET_QUEUE_OPTION);
export const setQueueRemovalOption = createAction(SET_QUEUE_REMOVAL_OPTION);
export const clearQueue = createAction(CLEAR_QUEUE);
export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM);
export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS);
export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM);
export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS);
//
// Helpers
const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details');
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'),
[FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) {
let params = payload;
// If the payload params are empty try to get params from state.
if (params && !_.isEmpty(params)) {
dispatch(set({ section: details, params }));
} else {
params = getState().queue.details.params;
}
// Ensure there are params before trying to fetch the queue
// so we don't make a bad request to the server.
if (params && !_.isEmpty(params)) {
fetchQueueDetailsHelper(getState, params, dispatch);
}
},
...createServerSideCollectionHandlers(
paged,
'/queue',
fetchQueue,
{
[serverSideCollectionHandlers.FETCH]: FETCH_QUEUE,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE,
[serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT,
[serverSideCollectionHandlers.FILTER]: SET_QUEUE_FILTER
},
fetchDataAugmenter
),
[GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) {
const id = payload.id;
dispatch(updateItem({ section: paged, id, isGrabbing: true }));
const promise = createAjaxRequest({
url: `/queue/grab/${id}`,
method: 'POST'
}).request;
promise.done((data) => {
dispatch(batchActions([
fetchQueue(),
set({
section: paged,
isGrabbing: false,
grabError: null
})
]));
});
promise.fail((xhr) => {
dispatch(updateItem({
section: paged,
id,
isGrabbing: false,
grabError: xhr
}));
});
},
[GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) {
const ids = payload.ids;
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isGrabbing: true
});
}),
set({
section: paged,
isGrabbing: true
})
]));
const promise = createAjaxRequest({
url: '/queue/grab/bulk',
method: 'POST',
dataType: 'json',
data: JSON.stringify(payload)
}).request;
promise.done((data) => {
dispatch(fetchQueue());
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isGrabbing: false,
grabError: null
});
}),
set({
section: paged,
isGrabbing: false,
grabError: null
})
]));
});
promise.fail((xhr) => {
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isGrabbing: false,
grabError: null
});
}),
set({ section: paged, isGrabbing: false })
]));
});
},
[REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) {
const {
id,
remove,
blocklist,
skipRedownload,
changeCategory
} = payload;
dispatch(updateItem({ section: paged, id, isRemoving: true }));
const promise = createAjaxRequest({
url: `/queue/${id}?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
method: 'DELETE'
}).request;
promise.done((data) => {
dispatch(fetchQueue());
});
promise.fail((xhr) => {
dispatch(updateItem({ section: paged, id, isRemoving: false }));
});
},
[REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) {
const {
ids,
remove,
blocklist,
skipRedownload,
changeCategory
} = payload;
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isRemoving: true
});
}),
set({ section: paged, isRemoving: true })
]));
const promise = createAjaxRequest({
url: `/queue/bulk?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
method: 'DELETE',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({ ids })
}).request;
promise.done((data) => {
// Don't use batchActions with thunks
dispatch(fetchQueue());
dispatch(set({ section: paged, isRemoving: false }));
});
promise.fail((xhr) => {
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isRemoving: false
});
}),
set({ section: paged, isRemoving: false })
]));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details),
[SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged),
[SET_QUEUE_OPTION]: function(state, { payload }) {
const queueOptions = state.options;
return {
...state,
options: {
...queueOptions,
...payload
}
};
},
[SET_QUEUE_REMOVAL_OPTION]: function(state, { payload }) {
const queueRemovalOptions = state.removalOptions;
return {
...state,
removalOptions: {
...queueRemovalOptions,
...payload
}
};
},
[CLEAR_QUEUE]: createClearReducer(paged, {
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
})
}, defaultState, section);
@@ -62,20 +62,6 @@ export const defaultState = {
isPopulated: false, isPopulated: false,
error: null, error: null,
items: [] items: []
},
logFiles: {
isFetching: false,
isPopulated: false,
error: null,
items: []
},
updateLogFiles: {
isFetching: false,
isPopulated: false,
error: null,
items: []
} }
}; };
@@ -94,11 +80,6 @@ export const RESTORE_BACKUP = 'system/backups/restoreBackup';
export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup'; export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup';
export const DELETE_BACKUP = 'system/backups/deleteBackup'; export const DELETE_BACKUP = 'system/backups/deleteBackup';
export const FETCH_UPDATES = 'system/updates/fetchUpdates';
export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles';
export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles';
export const RESTART = 'system/restart'; export const RESTART = 'system/restart';
export const SHUTDOWN = 'system/shutdown'; export const SHUTDOWN = 'system/shutdown';
@@ -117,11 +98,6 @@ export const restoreBackup = createThunk(RESTORE_BACKUP);
export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP); export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP);
export const deleteBackup = createThunk(DELETE_BACKUP); export const deleteBackup = createThunk(DELETE_BACKUP);
export const fetchUpdates = createThunk(FETCH_UPDATES);
export const fetchLogFiles = createThunk(FETCH_LOG_FILES);
export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES);
export const restart = createThunk(RESTART); export const restart = createThunk(RESTART);
export const shutdown = createThunk(SHUTDOWN); export const shutdown = createThunk(SHUTDOWN);
@@ -200,10 +176,6 @@ export const actionHandlers = handleThunks({
[DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'), [DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'),
[FETCH_UPDATES]: createFetchHandler('system.updates', '/update'),
[FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'),
[FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'),
[RESTART]: function(getState, payload, dispatch) { [RESTART]: function(getState, payload, dispatch) {
const promise = createAjaxRequest({ const promise = createAjaxRequest({
url: '/system/restart', url: '/system/restart',
@@ -1,31 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
export function createQueueItemSelectorForHook(episodeId: number) {
return createSelector(
(state: AppState) => state.queue.details.items,
(details) => {
if (!episodeId || !details) {
return null;
}
return details.find((item) => item.episodeId === episodeId);
}
);
}
function createQueueItemSelector() {
return createSelector(
(_: AppState, { episodeId }: { episodeId: number }) => episodeId,
(state: AppState) => state.queue.details.items,
(episodeId, details) => {
if (!episodeId || !details) {
return null;
}
return details.find((item) => item.episodeId === episodeId);
}
);
}
export default createQueueItemSelector;
+11 -4
View File
@@ -28,10 +28,17 @@ import useEvents, { useFilters } from './useEvents';
function LogsTable() { function LogsTable() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { data, error, isFetching, isFetched, isLoading, page, goToPage } = const {
useEvents(); records,
totalPages,
const { records = [], totalPages = 0, totalRecords } = data ?? {}; totalRecords,
error,
isFetching,
isFetched,
isLoading,
page,
goToPage,
} = useEvents();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } = const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useEventOptions(); useEventOptions();
@@ -1,17 +1,12 @@
import Column from 'Components/Table/Column'; import {
import { createPersist, mergeColumns } from 'Helpers/createPersist'; createOptionsStore,
import { SortDirection } from 'Helpers/Props/sortDirections'; PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
export interface EventOptions { export type EventOptions = PageableOptions;
pageSize: number;
selectedFilterKey: string | number;
sortKey: string;
sortDirection: SortDirection;
columns: Column[];
}
const eventOptionsStore = createPersist<EventOptions>( const { useOptions, setOptions, setOption } = createOptionsStore<EventOptions>(
'event_options', 'event_options',
() => { () => {
return { return {
@@ -57,29 +52,9 @@ const eventOptionsStore = createPersist<EventOptions>(
}, },
], ],
}; };
},
{
merge: mergeColumns,
} }
); );
export const useEventOptions = () => { export const useEventOptions = useOptions;
return eventOptionsStore((state) => state); export const setEventOptions = setOptions;
}; export const setEventOption = setOption;
export const setEventOptions = (options: Partial<EventOptions>) => {
eventOptionsStore.setState((state) => ({
...state,
...options,
}));
};
export const setEventOption = <K extends keyof EventOptions>(
key: K,
value: EventOptions[K]
) => {
eventOptionsStore.setState((state) => ({
...state,
[key]: value,
}));
};
+2 -10
View File
@@ -1,5 +1,5 @@
import { keepPreviousData } from '@tanstack/react-query'; import { keepPreviousData } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useMemo } from 'react';
import { Filter } from 'App/State/AppState'; import { Filter } from 'App/State/AppState';
import usePage from 'Helpers/Hooks/usePage'; import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery'; import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
@@ -69,17 +69,9 @@ const useEvents = () => {
}, },
}); });
const handleGoToPage = useCallback(
(page: number) => {
goToPage(page);
refetch();
},
[goToPage, refetch]
);
return { return {
...query, ...query,
goToPage: handleGoToPage, goToPage,
page, page,
refetch, refetch,
}; };
+8 -15
View File
@@ -1,46 +1,39 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchLogFiles } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import LogFiles from '../LogFiles'; import LogFiles from '../LogFiles';
import useLogFiles from '../useLogFiles';
function AppLogFiles() { function AppLogFiles() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { isFetching, items } = useSelector( const { data = [], isFetching, refetch } = useLogFiles();
(state: AppState) => state.system.logFiles
);
const isDeleteFilesExecuting = useSelector( const isDeleteFilesExecuting = useSelector(
createCommandExecutingSelector(commandNames.DELETE_LOG_FILES) createCommandExecutingSelector(commandNames.DELETE_LOG_FILES)
); );
const handleRefreshPress = useCallback(() => { const handleRefreshPress = useCallback(() => {
dispatch(fetchLogFiles()); refetch();
}, [dispatch]); }, [refetch]);
const handleDeleteFilesPress = useCallback(() => { const handleDeleteFilesPress = useCallback(() => {
dispatch( dispatch(
executeCommand({ executeCommand({
name: commandNames.DELETE_LOG_FILES, name: commandNames.DELETE_LOG_FILES,
commandFinished: () => { commandFinished: () => {
dispatch(fetchLogFiles()); refetch();
}, },
}) })
); );
}, [dispatch]); }, [dispatch, refetch]);
useEffect(() => {
dispatch(fetchLogFiles());
}, [dispatch]);
return ( return (
<LogFiles <LogFiles
isDeleteFilesExecuting={isDeleteFilesExecuting} isDeleteFilesExecuting={isDeleteFilesExecuting}
isFetching={isFetching} isFetching={isFetching}
items={items} items={data}
type="app" type="app"
onRefreshPress={handleRefreshPress} onRefreshPress={handleRefreshPress}
onDeleteFilesPress={handleDeleteFilesPress} onDeleteFilesPress={handleDeleteFilesPress}
@@ -1,46 +1,39 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchUpdateLogFiles } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import LogFiles from '../LogFiles'; import LogFiles from '../LogFiles';
import { useUpdateLogFiles } from '../useLogFiles';
function UpdateLogFiles() { function UpdateLogFiles() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { isFetching, items } = useSelector( const { data = [], isFetching, refetch } = useUpdateLogFiles();
(state: AppState) => state.system.updateLogFiles
);
const isDeleteFilesExecuting = useSelector( const isDeleteFilesExecuting = useSelector(
createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES) createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES)
); );
const handleRefreshPress = useCallback(() => { const handleRefreshPress = useCallback(() => {
dispatch(fetchUpdateLogFiles()); refetch();
}, [dispatch]); }, [refetch]);
const handleDeleteFilesPress = useCallback(() => { const handleDeleteFilesPress = useCallback(() => {
dispatch( dispatch(
executeCommand({ executeCommand({
name: commandNames.DELETE_UPDATE_LOG_FILES, name: commandNames.DELETE_UPDATE_LOG_FILES,
commandFinished: () => { commandFinished: () => {
dispatch(fetchUpdateLogFiles()); refetch();
}, },
}) })
); );
}, [dispatch]); }, [dispatch, refetch]);
useEffect(() => {
dispatch(fetchUpdateLogFiles());
}, [dispatch]);
return ( return (
<LogFiles <LogFiles
isDeleteFilesExecuting={isDeleteFilesExecuting} isDeleteFilesExecuting={isDeleteFilesExecuting}
isFetching={isFetching} isFetching={isFetching}
items={items} items={data}
type="update" type="update"
onRefreshPress={handleRefreshPress} onRefreshPress={handleRefreshPress}
onDeleteFilesPress={handleDeleteFilesPress} onDeleteFilesPress={handleDeleteFilesPress}
+14
View File
@@ -0,0 +1,14 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import LogFile from 'typings/LogFile';
export default function useLogFiles() {
return useApiQuery<LogFile[]>({
path: '/log/file',
});
}
export function useUpdateLogFiles() {
return useApiQuery<LogFile[]>({
path: '/log/file/update',
});
}
-2
View File
@@ -15,7 +15,6 @@ import { icons, kinds } from 'Helpers/Props';
import useUpdateSettings from 'Settings/General/useUpdateSettings'; import useUpdateSettings from 'Settings/General/useUpdateSettings';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
import { fetchUpdates } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
@@ -114,7 +113,6 @@ function Updates() {
}, [setIsMajorUpdateModalOpen]); }, [setIsMajorUpdateModalOpen]);
useEffect(() => { useEffect(() => {
dispatch(fetchUpdates());
dispatch(fetchGeneralSettings()); dispatch(fetchGeneralSettings());
}, [dispatch]); }, [dispatch]);
@@ -81,6 +81,10 @@ async function fetchJson<T, TData>({
throw new ApiError(path, response.status, response.statusText, body); throw new ApiError(path, response.status, response.statusText, body);
} }
if (response.status === 204) {
return {} as T;
}
return response.json() as T; return response.json() as T;
} }
+32 -19
View File
@@ -1,7 +1,13 @@
import { PropertyFilter } from 'App/State/AppState'; import { PropertyFilter } from 'App/State/AppState';
export interface QueryParams { export interface QueryParams {
[key: string]: string | number | boolean | PropertyFilter[] | undefined; [key: string]:
| string
| number
| boolean
| PropertyFilter[]
| number[]
| undefined;
} }
const getQueryString = (queryParams?: QueryParams) => { const getQueryString = (queryParams?: QueryParams) => {
@@ -9,27 +15,34 @@ const getQueryString = (queryParams?: QueryParams) => {
return ''; return '';
} }
const filteredParams = Object.keys(queryParams).reduce< const searchParams = Object.keys(queryParams).reduce<URLSearchParams>(
Record<string, string> (acc, key) => {
>((acc, key) => { const value = queryParams[key];
const value = queryParams[key];
if (value == null) {
return acc;
}
if (Array.isArray(value)) {
if (typeof value[0] === 'object') {
(value as PropertyFilter[]).forEach((filter) => {
acc.append(filter.key, String(filter.value));
});
} else {
value.forEach((item) => {
acc.append(key, String(item));
});
}
} else {
acc.append(key, String(value));
}
if (value == null) {
return acc; return acc;
} },
new URLSearchParams()
);
if (Array.isArray(value)) { const paramsString = searchParams.toString();
value.forEach((filter) => {
acc[filter.key] = String(filter.value);
});
} else {
acc[key] = String(value);
}
return acc;
}, {});
const paramsString = new URLSearchParams(filteredParams).toString();
return `?${paramsString}`; return `?${paramsString}`;
}; };
@@ -1,13 +1,25 @@
import KeysMatching from 'typings/Helpers/KeysMatching'; import KeysMatching from 'typings/Helpers/KeysMatching';
function selectUniqueIds<T, K>(items: T[], idProp: KeysMatching<T, K>) { function selectUniqueIds<T, K>(items: T[], idProp: KeysMatching<T, K>) {
return items.reduce((acc: K[], item) => { const result = items.reduce((acc: Set<K>, item) => {
if (item[idProp] && acc.indexOf(item[idProp] as K) === -1) { if (!item[idProp]) {
acc.push(item[idProp] as K); return acc;
}
const value = item[idProp] as K;
if (Array.isArray(value)) {
value.forEach((v) => {
acc.add(v);
});
} else {
acc.add(value);
} }
return acc; return acc;
}, []); }, new Set<K>());
return Array.from(result);
} }
export default selectUniqueIds; export default selectUniqueIds;
+1
View File
@@ -15,6 +15,7 @@ interface Blocklist extends ModelBase {
seriesId?: number; seriesId?: number;
indexer?: string; indexer?: string;
message?: string; message?: string;
source?: string;
} }
export default Blocklist; export default Blocklist;
+1
View File
@@ -37,6 +37,7 @@ export interface GrabbedHistoryData {
export interface DownloadFailedHistory { export interface DownloadFailedHistory {
message: string; message: string;
indexer?: string; indexer?: string;
source?: string;
} }
export interface DownloadFolderImportedHistory { export interface DownloadFolderImportedHistory {

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