1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-27 17:54:15 -04:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Mark McDowall
98c737a146 New: Move auth success logging to debug
Closes #7978
2025-08-10 21:15:05 -07:00
1197 changed files with 46126 additions and 71628 deletions

View File

@@ -2,7 +2,7 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet // README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
{ {
"name": "Sonarr", "name": "Sonarr",
"image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0", "image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0",
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": { "ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true, "nodeGypDependencies": true,

View File

@@ -21,13 +21,7 @@ runs:
using: "composite" using: "composite"
steps: steps:
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v5 uses: actions/setup-dotnet@v4
- name: Setup NuGet registry source
shell: bash
if: ${{ startsWith(inputs.runtime, 'freebsd') }}
run:
dotnet nuget add source --configfile src/NuGet.Config --name gh-openur https://nuget.pkg.github.com/openur/index.json --username ${{ github.repository_owner }} --password ${{ github.token }} --store-password-in-clear-text
- name: Setup Environment Variables - name: Setup Environment Variables
id: variables id: variables
@@ -92,7 +86,7 @@ runs:
echo "Building Sonarr for $runtime, Platform: $platform" echo "Building Sonarr for $runtime, Platform: $platform"
dotnet msbuild -restore $slnFile -p:SelfContained=true -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$runtime -p:EnableWindowsTargeting=true -t:PublishAllRids dotnet msbuild -restore $slnFile -p:SelfContained=True -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$runtime -p:EnableWindowsTargeting=true -t:PublishAllRids
- name: Package - name: Package
shell: bash shell: bash
@@ -188,7 +182,7 @@ runs:
runtime: ${{ inputs.runtime }} runtime: ${{ inputs.runtime }}
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: build-${{ inputs.runtime }} name: build-${{ inputs.runtime }}
path: _artifacts/**/* path: _artifacts/**/*

View File

@@ -25,13 +25,13 @@ runs:
using: "composite" using: "composite"
steps: steps:
- name: Download Artifact - name: Download Artifact
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
name: ${{ inputs.artifact }} name: ${{ inputs.artifact }}
path: _output path: _output
- name: Download UI Artifact - name: Download UI Artifact
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
name: build_ui name: build_ui
path: _output/UI path: _output/UI
@@ -67,7 +67,7 @@ runs:
build.bat build.bat
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: release-${{ inputs.runtime }} name: release-${{ inputs.runtime }}
compression-level: 0 compression-level: 0

View File

@@ -3,7 +3,7 @@
outputFolder=_output outputFolder=_output
artifactsFolder=_artifacts artifactsFolder=_artifacts
uiFolder="$outputFolder/UI" uiFolder="$outputFolder/UI"
framework="${FRAMEWORK:=net10.0}" framework="${FRAMEWORK:=net8.0}"
rm -rf $artifactsFolder rm -rf $artifactsFolder
mkdir $artifactsFolder mkdir $artifactsFolder

View File

@@ -12,7 +12,7 @@ inputs:
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
- uses: actions/upload-artifact@v7 - uses: actions/upload-artifact@v4
with: with:
name: tests-${{ inputs.runtime }} name: tests-${{ inputs.runtime }}
path: _tests/${{ inputs.framework }}/${{ inputs.runtime }}/publish/**/* path: _tests/${{ inputs.framework }}/${{ inputs.runtime }}/publish/**/*

View File

@@ -4,8 +4,6 @@ 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
@@ -29,18 +27,16 @@ runs:
using: 'composite' using: 'composite'
steps: steps:
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v5 uses: actions/setup-dotnet@v4
- name: Setup Postgres - name: Setup Postgres
if: ${{ inputs.use_postgres }} if: ${{ inputs.use_postgres }}
uses: ikalnytskyi/action-setup-postgres@v8 uses: ikalnytskyi/action-setup-postgres@v4
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' }}${{ inputs.use_postgres && inputs.postgres-version && inputs.postgres-version }}" >> "$GITHUB_ENV" echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}" >> "$GITHUB_ENV"
- name: Setup Postgres Environment Variables - name: Setup Postgres Environment Variables
if: ${{ inputs.use_postgres }} if: ${{ inputs.use_postgres }}
@@ -52,14 +48,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@v5 uses: actions/download-artifact@v4
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@v5 uses: actions/download-artifact@v4
with: with:
name: ${{ inputs.binary_artifact }} name: ${{ inputs.binary_artifact }}
path: _output path: _output
@@ -85,7 +81,7 @@ runs:
- name: Upload Test Results - name: Upload Test Results
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: results-${{ env.RESULTS_NAME }} name: results-${{ env.RESULTS_NAME }}
path: TestResults/*.trx path: TestResults/*.trx

View File

@@ -26,7 +26,7 @@ jobs:
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Setup dotnet - name: Setup dotnet
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4

View File

@@ -19,7 +19,7 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
env: env:
FRAMEWORK: net10.0 FRAMEWORK: net8.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }} RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 5 SONARR_MAJOR_VERSION: 5
VERSION: 5.0.0 VERSION: 5.0.0
@@ -82,7 +82,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v6 uses: actions/checkout@v4
- 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@v6 uses: actions/checkout@v4
- name: Volta - name: Volta
uses: volta-cli/action@v4 uses: volta-cli/action@v4
@@ -115,7 +115,7 @@ jobs:
run: yarn build --env production run: yarn build --env production
- name: Publish UI Artifact - name: Publish UI Artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: build_ui name: build_ui
path: _output/UI/**/* path: _output/UI/**/*
@@ -139,7 +139,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Test - name: Test
uses: ./.github/actions/test uses: ./.github/actions/test
@@ -152,13 +152,9 @@ 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, 18]
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Test - name: Test
uses: ./.github/actions/test uses: ./.github/actions/test
@@ -168,7 +164,6 @@ 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]
@@ -195,7 +190,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Test - name: Test
uses: ./.github/actions/test uses: ./.github/actions/test

View File

@@ -1,26 +0,0 @@
name: Close issues without labels
on:
issues:
types:
- opened
- reopened
jobs:
close-issue:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
sparse-checkout: |
.github
- name: Close issue if no labels found
if: join(github.event.issue.labels) == ''
run: |
gh issue comment ${{ github.event.issue.number }} --body ":wave: @${{ github.event.issue.user.login }}, this issue was closed automatically because it was created without following an issue template. Please update the issue following the correct template for this issue. Once updated please reply to this issue so we can review and re-open. In the future, use the [issue templates](https://github.com/${{ github.repository }}/issues/new/choose) instead of creating your own."
gh issue close ${{ github.event.issue.number }} --reason "not planned"
env:
GH_TOKEN: ${{ github.token }}

View File

@@ -52,7 +52,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Package - name: Package
uses: ./.github/actions/package uses: ./.github/actions/package
@@ -71,10 +71,10 @@ jobs:
contents: write contents: write
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Download release artifacts - name: Download release artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
path: _artifacts path: _artifacts
pattern: release-* pattern: release-*

2
.vscode/launch.json vendored
View File

@@ -10,7 +10,7 @@
"request": "launch", "request": "launch",
"preLaunchTask": "build dotnet", "preLaunchTask": "build dotnet",
// If you have changed target frameworks, make sure to update the program path. // If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/_output/net10.0/Sonarr", "program": "${workspaceFolder}/_output/net8.0/Sonarr",
"args": [], "args": [],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console

View File

@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { setQueueOptions } from 'Activity/Queue/queueOptionsStore'; import { useDispatch, useSelector } from 'react-redux';
import { SelectProvider, useSelect } from 'App/Select/SelectContext'; import { SelectProvider } from 'App/SelectContext';
import CommandNames from 'Commands/CommandNames'; import AppState from 'App/State/AppState';
import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; 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';
import FilterMenu from 'Components/Menu/FilterMenu'; import FilterMenu from 'Components/Menu/FilterMenu';
@@ -16,77 +16,94 @@ 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 { useCustomFiltersList } from 'Filters/useCustomFilters'; 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 { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections'; import {
import BlockListModel from 'typings/Blocklist'; clearBlocklist,
fetchBlocklist,
gotoBlocklistPage,
removeBlocklistItems,
setBlocklistFilter,
setBlocklistSort,
setBlocklistTableOption,
} from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs'; import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import { TableOptionsChangePayload } from 'typings/Table'; import { TableOptionsChangePayload } from 'typings/Table';
import { import {
registerPagePopulator, registerPagePopulator,
unregisterPagePopulator, unregisterPagePopulator,
} from 'Utilities/pagePopulator'; } from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import BlocklistFilterModal from './BlocklistFilterModal'; import BlocklistFilterModal from './BlocklistFilterModal';
import {
setBlocklistOption,
setBlocklistSort,
useBlocklistOptions,
} from './blocklistOptionsStore';
import BlocklistRow from './BlocklistRow'; import BlocklistRow from './BlocklistRow';
import useBlocklist, {
useFilters,
useRemoveBlocklistItems,
} from './useBlocklist';
function BlocklistContent() { function Blocklist() {
const requestCurrentPage = useCurrentPage();
const { const {
records, isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages, totalPages,
totalRecords, totalRecords,
isFetching, isRemoving,
isFetched, } = useSelector((state: AppState) => state.blocklist);
isLoading,
error,
page,
goToPage,
refetch,
} = useBlocklist();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } = const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
useBlocklistOptions(); const isClearingBlocklistExecuting = useSelector(
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST)
const filters = useFilters();
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
const customFilters = useCustomFiltersList('blocklist');
const executeCommand = useExecuteCommand();
const isClearingBlocklistExecuting = useCommandExecuting(
CommandNames.ClearBlocklist
); );
const dispatch = useDispatch();
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
useState(false); useState(false);
const [isConfirmClearModalOpen, setIsConfirmClearModalOpen] = useState(false); const [isConfirmClearModalOpen, setIsConfirmClearModalOpen] = useState(false);
const { const [selectState, setSelectState] = useSelectState();
allSelected, const { allSelected, allUnselected, selectedState } = selectState;
allUnselected,
anySelected, const selectedIds = useMemo(() => {
getSelectedIds, return getSelectedIds(selectedState);
selectAll, }, [selectedState]);
unselectAll,
} = useSelect<BlockListModel>(); const wasClearingBlocklistExecuting = usePrevious(
isClearingBlocklistExecuting
);
const handleSelectAllChange = useCallback( const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => { ({ value }: CheckInputChanged) => {
if (value) { setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
selectAll();
} else {
unselectAll();
}
}, },
[selectAll, unselectAll] [items, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
); );
const handleRemoveSelectedPress = useCallback(() => { const handleRemoveSelectedPress = useCallback(() => {
@@ -94,9 +111,9 @@ function BlocklistContent() {
}, [setIsConfirmRemoveModalOpen]); }, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback(() => { const handleRemoveSelectedConfirmed = useCallback(() => {
removeBlocklistItems({ ids: getSelectedIds() }); dispatch(removeBlocklistItems({ ids: selectedIds }));
setIsConfirmRemoveModalOpen(false); setIsConfirmRemoveModalOpen(false);
}, [getSelectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]); }, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]);
const handleConfirmRemoveModalClose = useCallback(() => { const handleConfirmRemoveModalClose = useCallback(() => {
setIsConfirmRemoveModalOpen(false); setIsConfirmRemoveModalOpen(false);
@@ -107,47 +124,66 @@ function BlocklistContent() {
}, [setIsConfirmClearModalOpen]); }, [setIsConfirmClearModalOpen]);
const handleClearBlocklistConfirmed = useCallback(() => { const handleClearBlocklistConfirmed = useCallback(() => {
executeCommand({ name: CommandNames.ClearBlocklist }, () => { dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST }));
goToPage(1);
});
setIsConfirmClearModalOpen(false); setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen, goToPage, executeCommand]); }, [setIsConfirmClearModalOpen, 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) => {
setBlocklistOption('selectedFilterKey', selectedFilterKey); dispatch(setBlocklistFilter({ selectedFilterKey }));
}, },
[] [dispatch]
); );
const handleSortPress = useCallback( const handleSortPress = useCallback(
(sortKey: string, sortDirection?: SortDirection) => { (sortKey: string) => {
setBlocklistSort({ dispatch(setBlocklistSort({ sortKey }));
sortKey,
sortDirection,
});
}, },
[] [dispatch]
); );
const handleTableOptionChange = useCallback( const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => { (payload: TableOptionsChangePayload) => {
setQueueOptions(payload); dispatch(setBlocklistTableOption(payload));
if (payload.pageSize) { if (payload.pageSize) {
goToPage(1); dispatch(gotoBlocklistPage({ page: 1 }));
} }
}, },
[goToPage] [dispatch]
); );
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchBlocklist());
} else {
dispatch(gotoBlocklistPage({ page: 1 }));
}
return () => {
dispatch(clearBlocklist());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
refetch(); dispatch(fetchBlocklist());
}; };
registerPagePopulator(repopulate); registerPagePopulator(repopulate);
@@ -155,129 +191,137 @@ function BlocklistContent() {
return () => { return () => {
unregisterPagePopulator(repopulate); unregisterPagePopulator(repopulate);
}; };
}, [refetch]); }, [dispatch]);
useEffect(() => {
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
dispatch(gotoBlocklistPage({ page: 1 }));
}
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
return ( return (
<PageContent title={translate('Blocklist')}> <SelectProvider items={items}>
<PageToolbar> <PageContent title={translate('Blocklist')}>
<PageToolbarSection> <PageToolbar>
<PageToolbarButton <PageToolbarSection>
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={!anySelected}
isSpinning={isRemoving}
onPress={handleRemoveSelectedPress}
/>
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!records.length}
isSpinning={isClearingBlocklistExecuting}
onPress={handleClearBlocklistPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton <PageToolbarButton
label={translate('Options')} label={translate('RemoveSelected')}
iconName={icons.TABLE} iconName={icons.REMOVE}
isDisabled={!selectedIds.length}
isSpinning={isRemoving}
onPress={handleRemoveSelectedPress}
/> />
</TableOptionsModalWrapper>
<FilterMenu <PageToolbarButton
alignMenu={align.RIGHT} label={translate('Clear')}
selectedFilterKey={selectedFilterKey} iconName={icons.CLEAR}
filters={filters} isDisabled={!items.length}
customFilters={customFilters} isSpinning={isClearingBlocklistExecuting}
filterModalConnectorComponent={BlocklistFilterModal} onPress={handleClearBlocklistPress}
onFilterSelect={handleFilterSelect} />
/> </PageToolbarSection>
</PageToolbarSection>
</PageToolbar>
<PageContentBody> <PageToolbarSection alignContent={align.RIGHT}>
{isLoading && !isFetched ? <LoadingIndicator /> : null} <TableOptionsModalWrapper
{!isLoading && !!error ? (
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
) : null}
{isFetched && !error && !records.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey === 'all'
? translate('NoBlocklistItems')
: translate('BlocklistFilterHasNoItems')}
</Alert>
) : null}
{isFetched && !error && !!records.length ? (
<div>
<Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns} columns={columns}
pageSize={pageSize} pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange} onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
> >
<TableBody> <PageToolbarButton
{records.map((item) => { label={translate('Options')}
return ( iconName={icons.TABLE}
<BlocklistRow key={item.id} columns={columns} {...item} /> />
); </TableOptionsModalWrapper>
})}
</TableBody> <FilterMenu
</Table> alignMenu={align.RIGHT}
<TablePager selectedFilterKey={selectedFilterKey}
page={page} filters={filters}
totalPages={totalPages} customFilters={customFilters}
totalRecords={totalRecords} filterModalConnectorComponent={BlocklistFilterModal}
isFetching={isFetching} onFilterSelect={handleFilterSelect}
onPageSelect={goToPage}
/> />
</div> </PageToolbarSection>
) : null} </PageToolbar>
</PageContentBody>
<ConfirmModal <PageContentBody>
isOpen={isConfirmRemoveModalOpen} {isFetching && !isPopulated ? <LoadingIndicator /> : null}
kind={kinds.DANGER}
title={translate('RemoveSelected')}
message={translate('RemoveSelectedBlocklistMessageText')}
confirmLabel={translate('RemoveSelected')}
onConfirm={handleRemoveSelectedConfirmed}
onCancel={handleConfirmRemoveModalClose}
/>
<ConfirmModal {!isFetching && !!error ? (
isOpen={isConfirmClearModalOpen} <Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
kind={kinds.DANGER} ) : null}
title={translate('ClearBlocklist')}
message={translate('ClearBlocklistMessageText')}
confirmLabel={translate('Clear')}
onConfirm={handleClearBlocklistConfirmed}
onCancel={handleConfirmClearModalClose}
/>
</PageContent>
);
}
function Blocklist() { {isPopulated && !error && !items.length ? (
const { records } = useBlocklist(); <Alert kind={kinds.INFO}>
{selectedFilterKey === 'all'
? translate('NoBlocklistItems')
: translate('BlocklistFilterHasNoItems')}
</Alert>
) : null}
return ( {isPopulated && !error && !!items.length ? (
<SelectProvider<BlockListModel> items={records}> <div>
<BlocklistContent /> <Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
return (
<BlocklistRow
key={item.id}
isSelected={selectedState[item.id] || false}
columns={columns}
{...item}
onSelectedChange={handleSelectedChange}
/>
);
})}
</TableBody>
</Table>
<TablePager
page={page}
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
</div>
) : null}
</PageContentBody>
<ConfirmModal
isOpen={isConfirmRemoveModalOpen}
kind={kinds.DANGER}
title={translate('RemoveSelected')}
message={translate('RemoveSelectedBlocklistMessageText')}
confirmLabel={translate('RemoveSelected')}
onConfirm={handleRemoveSelectedConfirmed}
onCancel={handleConfirmRemoveModalClose}
/>
<ConfirmModal
isOpen={isConfirmClearModalOpen}
kind={kinds.DANGER}
title={translate('ClearBlocklist')}
message={translate('ClearBlocklistMessageText')}
confirmLabel={translate('Clear')}
onConfirm={handleClearBlocklistConfirmed}
onCancel={handleConfirmClearModalClose}
/>
</PageContent>
</SelectProvider> </SelectProvider>
); );
} }

View File

@@ -16,19 +16,13 @@ interface BlocklistDetailsModalProps {
protocol: DownloadProtocol; protocol: DownloadProtocol;
indexer?: string; indexer?: string;
message?: string; message?: string;
source?: string;
onModalClose: () => void; onModalClose: () => void;
} }
function BlocklistDetailsModal({ function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
isOpen, const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } =
sourceTitle, props;
protocol,
indexer,
message,
source,
onModalClose,
}: BlocklistDetailsModalProps) {
return ( return (
<Modal isOpen={isOpen} onModalClose={onModalClose}> <Modal isOpen={isOpen} onModalClose={onModalClose}>
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
@@ -56,9 +50,6 @@ function BlocklistDetailsModal({
data={message} data={message}
/> />
) : null} ) : null}
{source ? (
<DescriptionListItem title={translate('Source')} data={source} />
) : null}
</DescriptionList> </DescriptionList>
</ModalBody> </ModalBody>

View File

@@ -1,26 +1,50 @@
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 { setBlocklistOption } from './blocklistOptionsStore'; import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
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 { records } = useBlocklist(); const sectionItems = useSelector(createBlocklistSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'blocklist';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback( const dispatchSetFilter = useCallback(
({ selectedFilterKey }: { selectedFilterKey: string | number }) => { (payload: unknown) => {
setBlocklistOption('selectedFilterKey', selectedFilterKey); dispatch(setBlocklistFilter(payload));
}, },
[] [dispatch]
); );
return ( return (
<FilterModal <FilterModal
{...props} {...props}
sectionItems={records} sectionItems={sectionItems}
filterBuilderProps={FILTER_BUILDER} filterBuilderProps={filterBuilderProps}
customFilterType="blocklist" customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter} dispatchSetFilter={dispatchSetFilter}
/> />
); );

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useSelect } from 'App/Select/SelectContext'; 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';
@@ -11,44 +11,40 @@ import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality'; 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 { useSingleSeries } 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 {
isSelected: boolean;
columns: Column[]; columns: Column[];
onSelectedChange: (options: SelectStateInputProps) => void;
} }
function BlocklistRow({ function BlocklistRow(props: BlocklistRowProps) {
id, const {
seriesId, id,
sourceTitle, seriesId,
languages, sourceTitle,
quality, languages,
customFormats, quality,
date, customFormats,
protocol, date,
indexer, protocol,
message, indexer,
source, message,
columns, isSelected,
}: BlocklistRowProps) { columns,
const series = useSingleSeries(seriesId); onSelectedChange,
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id); } = props;
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const { toggleSelected, useIsSelected } = useSelect<Blocklist>();
const isSelected = useIsSelected(id);
const handleSelectedChange = useCallback( const series = useSeries(seriesId);
({ id, value, shiftKey = false }: SelectStateInputProps) => { const dispatch = useDispatch();
toggleSelected({ id, isSelected: value, shiftKey }); const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
},
[toggleSelected]
);
const handleDetailsPress = useCallback(() => { const handleDetailsPress = useCallback(() => {
setIsDetailsModalOpen(true); setIsDetailsModalOpen(true);
@@ -59,8 +55,8 @@ function BlocklistRow({
}, [setIsDetailsModalOpen]); }, [setIsDetailsModalOpen]);
const handleRemovePress = useCallback(() => { const handleRemovePress = useCallback(() => {
removeBlocklistItem(); dispatch(removeBlocklistItem({ id }));
}, [removeBlocklistItem]); }, [id, dispatch]);
if (!series) { if (!series) {
return null; return null;
@@ -71,7 +67,7 @@ function BlocklistRow({
<TableSelectCell <TableSelectCell
id={id} id={id}
isSelected={isSelected} isSelected={isSelected}
onSelectedChange={handleSelectedChange} onSelectedChange={onSelectedChange}
/> />
{columns.map((column) => { {columns.map((column) => {
@@ -143,7 +139,6 @@ function BlocklistRow({
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>
@@ -159,7 +154,6 @@ function BlocklistRow({
protocol={protocol} protocol={protocol}
indexer={indexer} indexer={indexer}
message={message} message={message}
source={source}
onModalClose={handleDetailsModalClose} onModalClose={handleDetailsModalClose}
/> />
</TableRow> </TableRow>

View File

@@ -1,72 +0,0 @@
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import translate from 'Utilities/String/translate';
export type BlocklistOptions = PageableOptions;
const { useOptions, useOption, setOptions, setOption, setSort } =
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;
export const setBlocklistSort = setSort;

View File

@@ -1,113 +0,0 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { useCustomFiltersList } from 'Filters/useCustomFilters';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
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 = useCustomFiltersList('blocklist');
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,
};
};

View File

@@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux';
import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import { useUiSettingsValues } from 'Settings/UI/useUiSettings'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { import {
DownloadFailedHistory, DownloadFailedHistory,
DownloadFolderImportedHistory, DownloadFolderImportedHistory,
@@ -32,7 +33,9 @@ interface HistoryDetailsProps {
function HistoryDetails(props: HistoryDetailsProps) { function HistoryDetails(props: HistoryDetailsProps) {
const { eventType, sourceTitle, data, downloadId } = props; const { eventType, sourceTitle, data, downloadId } = props;
const { shortDateFormat, timeFormat } = useUiSettingsValues(); const { shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
if (eventType === 'grabbed') { if (eventType === 'grabbed') {
const { const {
@@ -171,7 +174,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
} }
if (eventType === 'downloadFailed') { if (eventType === 'downloadFailed') {
const { indexer, message, source } = data as DownloadFailedHistory; const { message, indexer } = data as DownloadFailedHistory;
return ( return (
<DescriptionList> <DescriptionList>
@@ -192,10 +195,6 @@ 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>
); );
} }

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React from 'react';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton'; import SpinnerButton from 'Components/Link/SpinnerButton';
import Modal from 'Components/Modal/Modal'; import Modal from 'Components/Modal/Modal';
@@ -9,7 +9,6 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { HistoryData, HistoryEventType } from 'typings/History'; import { HistoryData, HistoryEventType } from 'typings/History';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import { useMarkAsFailed } from '../useHistory';
import HistoryDetails from './HistoryDetails'; import HistoryDetails from './HistoryDetails';
import styles from './HistoryDetailsModal.css'; import styles from './HistoryDetailsModal.css';
@@ -34,32 +33,26 @@ function getHeaderTitle(eventType: HistoryEventType) {
interface HistoryDetailsModalProps { interface HistoryDetailsModalProps {
isOpen: boolean; isOpen: boolean;
id: number;
eventType: HistoryEventType; eventType: HistoryEventType;
sourceTitle: string; sourceTitle: string;
data: HistoryData; data: HistoryData;
downloadId?: string; downloadId?: string;
isMarkingAsFailed: boolean;
onMarkAsFailedPress: () => void;
onModalClose: () => void; onModalClose: () => void;
} }
function HistoryDetailsModal(props: HistoryDetailsModalProps) { function HistoryDetailsModal(props: HistoryDetailsModalProps) {
const { isOpen, id, eventType, sourceTitle, data, downloadId, onModalClose } = const {
props; isOpen,
eventType,
const { markAsFailed, isMarkingAsFailed, markAsFailedError } = sourceTitle,
useMarkAsFailed(id); data,
downloadId,
const wasMarkingAsFailed = useRef(isMarkingAsFailed); isMarkingAsFailed = false,
onMarkAsFailedPress,
const handleMarkAsFailedPress = useCallback(() => { onModalClose,
markAsFailed(); } = props;
}, [markAsFailed]);
useEffect(() => {
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
onModalClose();
}
}, [wasMarkingAsFailed, isMarkingAsFailed, markAsFailedError, onModalClose]);
return ( return (
<Modal isOpen={isOpen} onModalClose={onModalClose}> <Modal isOpen={isOpen} onModalClose={onModalClose}>
@@ -81,7 +74,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
className={styles.markAsFailedButton} className={styles.markAsFailedButton}
kind={kinds.DANGER} kind={kinds.DANGER}
isSpinning={isMarkingAsFailed} isSpinning={isMarkingAsFailed}
onPress={handleMarkAsFailedPress} onPress={onMarkAsFailedPress}
> >
{translate('MarkAsFailed')} {translate('MarkAsFailed')}
</SpinnerButton> </SpinnerButton>

View File

@@ -1,4 +1,6 @@
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu'; import FilterMenu from 'Components/Menu/FilterMenu';
@@ -11,10 +13,21 @@ 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 useEpisodes from 'Episode/useEpisodes'; import usePaging from 'Components/Table/usePaging';
import { useCustomFiltersList } from 'Filters/useCustomFilters'; import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections'; import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
import {
clearHistory,
fetchHistory,
gotoHistoryPage,
setHistoryFilter,
setHistorySort,
setHistoryTableOption,
} from 'Store/Actions/historyActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import HistoryItem from 'typings/History'; import HistoryItem from 'typings/History';
import { TableOptionsChangePayload } from 'typings/Table'; import { TableOptionsChangePayload } from 'typings/Table';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
@@ -24,86 +37,100 @@ import {
} from 'Utilities/pagePopulator'; } from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import HistoryFilterModal from './HistoryFilterModal'; import HistoryFilterModal from './HistoryFilterModal';
import {
setHistoryOption,
setHistoryOptions,
setHistorySort,
useHistoryOptions,
} from './historyOptionsStore';
import HistoryRow from './HistoryRow'; import HistoryRow from './HistoryRow';
import useHistory, { useFilters } from './useHistory';
function History() { function History() {
const requestCurrentPage = useCurrentPage();
const { const {
records, isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages, totalPages,
totalRecords, totalRecords,
error, } = useSelector((state: AppState) => state.history);
isFetching,
isFetched,
isLoading,
page,
goToPage,
refetch,
} = useHistory();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } = const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useHistoryOptions(); useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('history'));
const dispatch = useDispatch();
const episodeIds = useMemo(() => { const isFetchingAny = isFetching || isEpisodesFetching;
return selectUniqueIds<HistoryItem, number>(records, 'episodeId'); const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
}, [records]); const hasError = error || episodesError;
const { const {
isFetching: isEpisodesFetching, handleFirstPagePress,
isFetched: isEpisodesFetched, handlePreviousPagePress,
error: episodesError, handleNextPagePress,
} = useEpisodes({ episodeIds }); handleLastPagePress,
handlePageSelect,
const filters = useFilters(); } = usePaging({
page,
const customFilters = useCustomFiltersList('history'); totalPages,
gotoPage: gotoHistoryPage,
const isFetchingAny = isLoading || isEpisodesFetching; });
const isAllPopulated = isFetched && (isEpisodesFetched || !records.length);
const hasError = error || episodesError;
const handleFilterSelect = useCallback( const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => { (selectedFilterKey: string | number) => {
setHistoryOption('selectedFilterKey', selectedFilterKey); dispatch(setHistoryFilter({ selectedFilterKey }));
}, },
[] [dispatch]
); );
const handleSortPress = useCallback( const handleSortPress = useCallback(
(sortKey: string, sortDirection?: SortDirection) => { (sortKey: string) => {
setHistorySort({ dispatch(setHistorySort({ sortKey }));
sortKey,
sortDirection,
});
}, },
[] [dispatch]
); );
const handleTableOptionChange = useCallback( const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => { (payload: TableOptionsChangePayload) => {
setHistoryOptions(payload); dispatch(setHistoryTableOption(payload));
if (payload.pageSize) { if (payload.pageSize) {
goToPage(1); dispatch(gotoHistoryPage({ page: 1 }));
} }
}, },
[goToPage] [dispatch]
); );
const handleRefreshPress = useCallback(() => { useEffect(() => {
goToPage(1); if (requestCurrentPage) {
refetch(); dispatch(fetchHistory());
}, [goToPage, refetch]); } else {
dispatch(gotoHistoryPage({ page: 1 }));
}
return () => {
dispatch(clearHistory());
dispatch(clearEpisodes());
dispatch(clearEpisodeFiles());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const episodeIds = selectUniqueIds<HistoryItem, number>(items, 'episodeId');
if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds }));
} else {
dispatch(clearEpisodes());
}
}, [items, dispatch]);
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
refetch(); dispatch(fetchHistory());
}; };
registerPagePopulator(repopulate); registerPagePopulator(repopulate);
@@ -111,7 +138,7 @@ function History() {
return () => { return () => {
unregisterPagePopulator(repopulate); unregisterPagePopulator(repopulate);
}; };
}, [refetch]); }, [dispatch]);
return ( return (
<PageContent title={translate('History')}> <PageContent title={translate('History')}>
@@ -121,7 +148,7 @@ function History() {
label={translate('Refresh')} label={translate('Refresh')}
iconName={icons.REFRESH} iconName={icons.REFRESH}
isSpinning={isFetching} isSpinning={isFetching}
onPress={handleRefreshPress} onPress={handleFirstPagePress}
/> />
</PageToolbarSection> </PageToolbarSection>
@@ -159,12 +186,12 @@ function History() {
// If history isPopulated and it's empty show no history found and don't // If history isPopulated and it's empty show no history found and don't
// wait for the episodes to populate because they are never coming. // wait for the episodes to populate because they are never coming.
isFetched && !hasError && !records.length ? ( isPopulated && !hasError && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert> <Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
) : null ) : null
} }
{isAllPopulated && !hasError && records.length ? ( {isAllPopulated && !hasError && items.length ? (
<div> <div>
<Table <Table
columns={columns} columns={columns}
@@ -175,7 +202,7 @@ function History() {
onSortPress={handleSortPress} onSortPress={handleSortPress}
> >
<TableBody> <TableBody>
{records.map((item) => { {items.map((item) => {
return ( return (
<HistoryRow key={item.id} columns={columns} {...item} /> <HistoryRow key={item.id} columns={columns} {...item} />
); );
@@ -188,7 +215,11 @@ function History() {
totalPages={totalPages} totalPages={totalPages}
totalRecords={totalRecords} totalRecords={totalRecords}
isFetching={isFetching} isFetching={isFetching}
onPageSelect={goToPage} onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/> />
</div> </div>
) : null} ) : null}

View File

@@ -1,25 +1,48 @@
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 { setHistoryOption } from './historyOptionsStore'; import { setHistoryFilter } from 'Store/Actions/historyActions';
import useHistory, { FILTER_BUILDER } from './useHistory';
function createHistorySelector() {
return createSelector(
(state: AppState) => state.history.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.history.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
type HistoryFilterModalProps = FilterModalProps<History>; type HistoryFilterModalProps = FilterModalProps<History>;
export default function HistoryFilterModal(props: HistoryFilterModalProps) { export default function HistoryFilterModal(props: HistoryFilterModalProps) {
const { records } = useHistory(); const sectionItems = useSelector(createHistorySelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const dispatch = useDispatch();
const dispatchSetFilter = useCallback( const dispatchSetFilter = useCallback(
({ selectedFilterKey }: { selectedFilterKey: string | number }) => { (payload: { selectedFilterKey: string | number }) => {
setHistoryOption('selectedFilterKey', selectedFilterKey); dispatch(setHistoryFilter(payload));
}, },
[] [dispatch]
); );
return ( return (
<FilterModal <FilterModal
{...props} {...props}
sectionItems={records} sectionItems={sectionItems}
filterBuilderProps={FILTER_BUILDER} filterBuilderProps={filterBuilderProps}
customFilterType="history" customFilterType="history"
dispatchSetFilter={dispatchSetFilter} dispatchSetFilter={dispatchSetFilter}
/> />

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, 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 +13,13 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import useEpisode from 'Episode/useEpisode'; import useEpisode from 'Episode/useEpisode';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, tooltipPositions } from 'Helpers/Props'; import { icons, tooltipPositions } from 'Helpers/Props';
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 { useSingleSeries } from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
import CustomFormat from 'typings/CustomFormat'; import CustomFormat from 'typings/CustomFormat';
import { HistoryData, HistoryEventType } from 'typings/History'; import { HistoryData, HistoryEventType } from 'typings/History';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
@@ -58,10 +61,14 @@ function HistoryRow(props: HistoryRowProps) {
date, date,
data, data,
downloadId, downloadId,
isMarkingAsFailed = false,
markAsFailedError,
columns, columns,
} = props; } = props;
const series = useSingleSeries(seriesId); const wasMarkingAsFailed = usePrevious(isMarkingAsFailed);
const dispatch = useDispatch();
const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes'); const episode = useEpisode(episodeId, 'episodes');
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
@@ -74,6 +81,23 @@ function HistoryRow(props: HistoryRowProps) {
setIsDetailsModalOpen(false); setIsDetailsModalOpen(false);
}, [setIsDetailsModalOpen]); }, [setIsDetailsModalOpen]);
const handleMarkAsFailedPress = useCallback(() => {
dispatch(markAsFailed({ id }));
}, [id, dispatch]);
useEffect(() => {
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
setIsDetailsModalOpen(false);
dispatch(fetchHistory());
}
}, [
wasMarkingAsFailed,
isMarkingAsFailed,
markAsFailedError,
setIsDetailsModalOpen,
dispatch,
]);
if (!series || !episode) { if (!series || !episode) {
return null; return null;
} }
@@ -230,12 +254,13 @@ function HistoryRow(props: HistoryRowProps) {
})} })}
<HistoryDetailsModal <HistoryDetailsModal
id={id}
isOpen={isDetailsModalOpen} isOpen={isDetailsModalOpen}
eventType={eventType} eventType={eventType}
sourceTitle={sourceTitle} sourceTitle={sourceTitle}
data={data} data={data}
downloadId={downloadId} downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
onMarkAsFailedPress={handleMarkAsFailedPress}
onModalClose={handleDetailsModalClose} onModalClose={handleDetailsModalClose}
/> />
</TableRow> </TableRow>

View File

@@ -1,109 +0,0 @@
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';
export type HistoryOptions = PageableOptions;
const { useOptions, useOption, setOptions, setOption, setSort } =
createOptionsStore<HistoryOptions>('history_options', () => {
return {
pageSize: 20,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'eventType',
label: '',
columnLabel: () => translate('EventType'),
isVisible: true,
isModifiable: false,
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true,
},
{
name: 'episode',
label: () => translate('Episode'),
isVisible: true,
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
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: 'downloadClient',
label: () => translate('DownloadClient'),
isVisible: false,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isVisible: false,
},
{
name: 'releaseGroup',
label: () => translate('ReleaseGroup'),
isVisible: false,
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: false,
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isVisible: false,
},
{
name: 'details',
label: '',
columnLabel: () => translate('Details'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useHistoryOptions = useOptions;
export const setHistoryOptions = setOptions;
export const useHistoryOption = useOption;
export const setHistoryOption = setOption;
export const setHistorySort = setSort;

View File

@@ -1,20 +0,0 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import History from 'typings/History';
const DEFAULT_HISTORY: History[] = [];
const useEpisodeHistory = (episodeId: number) => {
const { data, ...result } = useApiQuery<History[]>({
path: '/history/episode',
queryParams: {
episodeId,
},
});
return {
data: data ?? DEFAULT_HISTORY,
...result,
};
};
export default useEpisodeHistory;

View File

@@ -1,192 +0,0 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { useCustomFiltersList } from 'Filters/useCustomFilters';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
import History from 'typings/History';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { useHistoryOptions } from './historyOptionsStore';
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
{
key: 'grabbed',
label: () => translate('Grabbed'),
filters: [
{
key: 'eventType',
value: '1',
type: 'equal',
},
],
},
{
key: 'imported',
label: () => translate('Imported'),
filters: [
{
key: 'eventType',
value: '3',
type: 'equal',
},
],
},
{
key: 'failed',
label: () => translate('Failed'),
filters: [
{
key: 'eventType',
value: '4',
type: 'equal',
},
],
},
{
key: 'deleted',
label: () => translate('Deleted'),
filters: [
{
key: 'eventType',
value: '5',
type: 'equal',
},
],
},
{
key: 'renamed',
label: () => translate('Renamed'),
filters: [
{
key: 'eventType',
value: '6',
type: 'equal',
},
],
},
{
key: 'ignored',
label: () => translate('Ignored'),
filters: [
{
key: 'eventType',
value: '7',
type: 'equal',
},
],
},
];
export const FILTER_BUILDER: FilterBuilderProp<History>[] = [
{
name: 'eventType',
label: () => translate('EventType'),
type: 'equal',
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE,
},
{
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,
},
];
type HistoryType = 'episode' | 'series';
const MARK_AS_FAILED_QUERY_KEYS: Record<HistoryType, string> = {
episode: '/history/episode',
series: '/history/series',
} as const;
const useHistory = () => {
const { page, goToPage } = usePage('history');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useHistoryOptions();
const customFilters = useCustomFiltersList('history');
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<History>({
path: '/history',
page,
pageSize,
filters,
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
const handleGoToPage = useCallback(
(page: number) => {
goToPage(page);
},
[goToPage]
);
return {
...query,
goToPage: handleGoToPage,
page,
refetch,
};
};
export default useHistory;
export const useFilters = () => {
return FILTERS;
};
export const useMarkAsFailed = (id: number, type?: HistoryType) => {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/history/failed/${id}`,
method: 'POST',
mutationOptions: {
onMutate: () => {
setError(null);
},
onSuccess: () => {
const queryKey = type ? MARK_AS_FAILED_QUERY_KEYS[type] : '/history';
queryClient.invalidateQueries({ queryKey: [queryKey] });
},
onError: () => {
setError('Error marking history item as failed');
},
},
});
return {
markAsFailed: mutate,
isMarkingAsFailed: isPending,
markAsFailedError: error,
};
};

View File

@@ -1,24 +0,0 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import History from 'typings/History';
const DEFAULT_HISTORY: History[] = [];
const useSeriesHistory = (
seriesId: number,
seasonNumber: number | undefined
) => {
const { data, ...result } = useApiQuery<History[]>({
path: '/history/series',
queryParams: {
seriesId,
seasonNumber,
},
});
return {
data: data ?? DEFAULT_HISTORY,
...result,
};
};
export default useSeriesHistory;

View File

@@ -1,114 +0,0 @@
import React, {
createContext,
PropsWithChildren,
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;
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
export default function QueueDetailsProvider({
children,
...filter
}: PropsWithChildren<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) ?? [];
};

View File

@@ -1,76 +0,0 @@
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}
/>
</>
);
}

View File

@@ -1,13 +0,0 @@
.multiple {
cursor: default;
}
.row {
display: flex;
}
.episodeNumber {
margin-right: 8px;
font-weight: bold;
cursor: default;
}

View File

@@ -1,9 +0,0 @@
// 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;

View File

@@ -1,66 +0,0 @@
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"
/>
);
}

View File

@@ -6,9 +6,9 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { SelectProvider, useSelect } from 'App/Select/SelectContext'; import { useDispatch, useSelector } from 'react-redux';
import CommandNames from 'Commands/CommandNames'; import AppState from 'App/State/AppState';
import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; 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';
import FilterMenu from 'Components/Menu/FilterMenu'; import FilterMenu from 'Components/Menu/FilterMenu';
@@ -22,13 +22,28 @@ 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 useEpisodes from 'Episode/useEpisodes'; import usePaging from 'Components/Table/usePaging';
import { useCustomFiltersList } from 'Filters/useCustomFilters'; import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections'; import { executeCommand } from 'Store/Actions/commandActions';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; 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 createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs'; import { CheckInputChanged } from 'typings/inputs';
import QueueModel from 'typings/Queue'; 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 {
@@ -36,185 +51,192 @@ import {
unregisterPagePopulator, unregisterPagePopulator,
} from 'Utilities/pagePopulator'; } from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import QueueFilterModal from './QueueFilterModal'; import QueueFilterModal from './QueueFilterModal';
import { import QueueOptions from './QueueOptions';
setQueueOption,
setQueueOptions,
setQueueSort,
useQueueOptions,
} from './queueOptionsStore';
import QueueRow from './QueueRow'; import QueueRow from './QueueRow';
import RemoveQueueItemModal from './RemoveQueueItemModal'; import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
import useQueueStatus from './Status/useQueueStatus'; import createQueueStatusSelector from './Status/createQueueStatusSelector';
import useQueue, {
useFilters,
useGrabQueueItems,
useRemoveQueueItems,
} from './useQueue';
function QueueContent() { function Queue() {
const executeCommand = useExecuteCommand(); const requestCurrentPage = useCurrentPage();
const dispatch = useDispatch();
const { const {
records, isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages, totalPages,
totalRecords, totalRecords,
error, isGrabbing,
isFetching, isRemoving,
isLoading, } = useSelector((state: AppState) => state.queue.paged);
page,
goToPage,
refetch,
} = useQueue();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } = const { count } = useSelector(createQueueStatusSelector());
useQueueOptions(); const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('queue'));
const filters = useFilters(); const isRefreshMonitoredDownloadsExecuting = useSelector(
createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS)
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
const { count } = useQueueStatus();
const episodeIds = useMemo(() => {
return selectUniqueIds<QueueModel, number>(records, 'episodeIds');
}, [records]);
const {
isFetching: isEpisodesFetching,
isFetched: isEpisodesFetched,
error: episodesError,
} = useEpisodes({ episodeIds });
const customFilters = useCustomFiltersList('queue');
const isRefreshMonitoredDownloadsExecuting = useCommandExecuting(
CommandNames.RefreshMonitoredDownloads
); );
const shouldBlockRefresh = useRef(false); const shouldBlockRefresh = useRef(false);
const currentQueue = useRef<ReactElement | null>(null); const currentQueue = useRef<ReactElement | null>(null);
const { allSelected, allUnselected, selectAll, unselectAll, useSelectedIds } = const [selectState, setSelectState] = useSelectState();
useSelect<QueueModel>(); const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedIds = useSelectedIds();
const isPendingSelected = useMemo(() => { const isPendingSelected = useMemo(() => {
return records.some((item) => { return items.some((item) => {
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
}); });
}, [records, selectedIds]); }, [items, selectedIds]);
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
useState(false); useState(false);
const [isInteractiveImportDownloadIds, setIsInteractiveImportDownloadIds] =
useState<string[]>(() => []);
const isRefreshing = const isRefreshing =
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
// Use isLoading over isFetched to avoid losing the table UI when switching pages
const isAllPopulated = const isAllPopulated =
!isLoading && isPopulated &&
(isEpisodesFetched || (isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
!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) => {
if (value) { setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
selectAll();
} else {
unselectAll();
}
}, },
[selectAll, unselectAll] [items, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
); );
const handleRefreshPress = useCallback(() => { const handleRefreshPress = useCallback(() => {
executeCommand({ dispatch(
name: CommandNames.RefreshMonitoredDownloads, executeCommand({
}); name: commandNames.REFRESH_MONITORED_DOWNLOADS,
}, [executeCommand]); })
);
}, [dispatch]);
const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => { const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => {
shouldBlockRefresh.current = isOpen; shouldBlockRefresh.current = isOpen;
}, []); }, []);
const handleGrabSelectedPress = useCallback(() => { const handleGrabSelectedPress = useCallback(() => {
grabQueueItems({ ids: selectedIds }); dispatch(grabQueueItems({ ids: selectedIds }));
}, [selectedIds, grabQueueItems]); }, [selectedIds, dispatch]);
const handleRemoveSelectedPress = useCallback(() => { const handleRemoveSelectedPress = useCallback(() => {
shouldBlockRefresh.current = true; shouldBlockRefresh.current = true;
setIsConfirmRemoveModalOpen(true); setIsConfirmRemoveModalOpen(true);
}, [setIsConfirmRemoveModalOpen]); }, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback(() => { const handleRemoveSelectedConfirmed = useCallback(
shouldBlockRefresh.current = false; (payload: RemovePressProps) => {
removeQueueItems({ ids: selectedIds }); shouldBlockRefresh.current = false;
setIsConfirmRemoveModalOpen(false); dispatch(removeQueueItems({ ids: selectedIds, ...payload }));
}, [selectedIds, removeQueueItems]); setIsConfirmRemoveModalOpen(false);
},
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
);
const handleConfirmRemoveModalClose = useCallback(() => { const handleConfirmRemoveModalClose = useCallback(() => {
shouldBlockRefresh.current = false; shouldBlockRefresh.current = false;
setIsConfirmRemoveModalOpen(false); setIsConfirmRemoveModalOpen(false);
}, []); }, [setIsConfirmRemoveModalOpen]);
const handleImportSelectedPress = useCallback(() => { const {
shouldBlockRefresh.current = true; handleFirstPagePress,
setIsInteractiveImportDownloadIds( handlePreviousPagePress,
selectedIds handleNextPagePress,
.map((id) => { handleLastPagePress,
const item = records.find((i) => i.id === id); handlePageSelect,
} = usePaging({
return item?.downloadId; page,
}) totalPages,
.filter((id): id is string => !!id) gotoPage: gotoQueuePage,
); });
}, [records, selectedIds]);
const handleImportSelectedModalClose = useCallback(() => {
shouldBlockRefresh.current = false;
setIsInteractiveImportDownloadIds([]);
}, []);
const handleFilterSelect = useCallback( const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => { (selectedFilterKey: string | number) => {
setQueueOption('selectedFilterKey', selectedFilterKey); dispatch(setQueueFilter({ selectedFilterKey }));
}, },
[] [dispatch]
); );
const handleSortPress = useCallback( const handleSortPress = useCallback(
(sortKey: string, sortDirection?: SortDirection) => { (sortKey: string) => {
setQueueSort({ dispatch(setQueueSort({ sortKey }));
sortKey,
sortDirection,
});
}, },
[] [dispatch]
); );
const handleTableOptionChange = useCallback( const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => { (payload: TableOptionsChangePayload) => {
setQueueOptions(payload); dispatch(setQueueTableOption(payload));
if (payload.pageSize) { if (payload.pageSize) {
goToPage(1); dispatch(gotoQueuePage({ page: 1 }));
} }
}, },
[goToPage] [dispatch]
); );
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchQueue());
} else {
dispatch(gotoQueuePage({ page: 1 }));
}
return () => {
dispatch(clearQueue());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
items,
'episodeId'
);
if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds }));
} else {
dispatch(clearEpisodes());
}
}, [items, dispatch]);
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
refetch(); dispatch(fetchQueue());
}; };
registerPagePopulator(repopulate); registerPagePopulator(repopulate);
@@ -222,7 +244,7 @@ function QueueContent() {
return () => { return () => {
unregisterPagePopulator(repopulate); unregisterPagePopulator(repopulate);
}; };
}, [refetch]); }, [dispatch]);
if (!shouldBlockRefresh.current) { if (!shouldBlockRefresh.current) {
currentQueue.current = ( currentQueue.current = (
@@ -233,7 +255,7 @@ function QueueContent() {
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert> <Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
) : null} ) : null}
{isAllPopulated && !hasError && !records.length ? ( {isAllPopulated && !hasError && !items.length ? (
<Alert kind={kinds.INFO}> <Alert kind={kinds.INFO}>
{selectedFilterKey !== 'all' && count > 0 {selectedFilterKey !== 'all' && count > 0
? translate('QueueFilterHasNoItems') ? translate('QueueFilterHasNoItems')
@@ -241,7 +263,7 @@ function QueueContent() {
</Alert> </Alert>
) : null} ) : null}
{isAllPopulated && !hasError && !!records.length ? ( {isAllPopulated && !hasError && !!items.length ? (
<div> <div>
<Table <Table
selectAll={true} selectAll={true}
@@ -251,17 +273,21 @@ function QueueContent() {
pageSize={pageSize} pageSize={pageSize}
sortKey={sortKey} sortKey={sortKey}
sortDirection={sortDirection} sortDirection={sortDirection}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange} onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange} onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress} onSortPress={handleSortPress}
> >
<TableBody> <TableBody>
{records.map((item) => { {items.map((item) => {
return ( return (
<QueueRow <QueueRow
key={item.id} key={item.id}
episodeId={item.episodeId}
isSelected={selectedState[item.id]}
columns={columns} columns={columns}
{...item} {...item}
onSelectedChange={handleSelectedChange}
onQueueRowModalOpenOrClose={ onQueueRowModalOpenOrClose={
handleQueueRowModalOpenOrClose handleQueueRowModalOpenOrClose
} }
@@ -276,7 +302,11 @@ function QueueContent() {
totalPages={totalPages} totalPages={totalPages}
totalRecords={totalRecords} totalRecords={totalRecords}
isFetching={isFetching} isFetching={isFetching}
onPageSelect={goToPage} onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/> />
</div> </div>
) : null} ) : null}
@@ -312,15 +342,6 @@ function QueueContent() {
isSpinning={isRemoving} isSpinning={isRemoving}
onPress={handleRemoveSelectedPress} onPress={handleRemoveSelectedPress}
/> />
<PageToolbarSeparator />
<PageToolbarButton
label={translate('ImportSelected')}
iconName={icons.INTERACTIVE}
isDisabled={disableSelectedActions}
onPress={handleImportSelectedPress}
/>
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}> <PageToolbarSection alignContent={align.RIGHT}>
@@ -328,6 +349,7 @@ function QueueContent() {
columns={columns} columns={columns}
pageSize={pageSize} pageSize={pageSize}
maxPageSize={200} maxPageSize={200}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange} onTableOptionChange={handleTableOptionChange}
> >
<PageToolbarButton <PageToolbarButton
@@ -354,24 +376,24 @@ function QueueContent() {
selectedCount={selectedCount} selectedCount={selectedCount}
canChangeCategory={ canChangeCategory={
isConfirmRemoveModalOpen && isConfirmRemoveModalOpen &&
selectedIds.every((id: number) => { selectedIds.every((id) => {
const item = records.find((i) => i.id === id); const item = items.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory); return !!(item && item.downloadClientHasPostImportCategory);
}) })
} }
canIgnore={ canIgnore={
isConfirmRemoveModalOpen && isConfirmRemoveModalOpen &&
selectedIds.every((id: number) => { selectedIds.every((id) => {
const item = records.find((i) => i.id === id); const item = items.find((i) => i.id === id);
return !!(item && item.seriesId && item.episodeId); return !!(item && item.seriesId && item.episodeId);
}) })
} }
isPending={ isPending={
isConfirmRemoveModalOpen && isConfirmRemoveModalOpen &&
selectedIds.every((id: number) => { selectedIds.every((id) => {
const item = records.find((i) => i.id === id); const item = items.find((i) => i.id === id);
if (!item) { if (!item) {
return false; return false;
@@ -386,25 +408,8 @@ function QueueContent() {
onRemovePress={handleRemoveSelectedConfirmed} onRemovePress={handleRemoveSelectedConfirmed}
onModalClose={handleConfirmRemoveModalClose} onModalClose={handleConfirmRemoveModalClose}
/> />
<InteractiveImportModal
isOpen={isInteractiveImportDownloadIds.length > 0}
downloadIds={isInteractiveImportDownloadIds}
title={translate('InteractiveImportMultipleQueueItems')}
onModalClose={handleImportSelectedModalClose}
/>
</PageContent> </PageContent>
); );
} }
function Queue() {
const { records } = useQueue();
return (
<SelectProvider<QueueModel> items={records}>
<QueueContent />
</SelectProvider>
);
}
export default Queue; export default Queue;

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';

View File

@@ -1,24 +1,50 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { SetFilter } from 'Components/Filter/Filter'; 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 { setQueueOption } from './queueOptionsStore'; import { setQueueFilter } from 'Store/Actions/queueActions';
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 { records } = useQueue(); const sectionItems = useSelector(createQueueSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'queue';
const dispatchSetFilter = useCallback(({ selectedFilterKey }: SetFilter) => { const dispatch = useDispatch();
setQueueOption('selectedFilterKey', selectedFilterKey);
}, []); const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setQueueFilter(payload));
},
[dispatch]
);
return ( return (
<FilterModal <FilterModal
{...props} {...props}
sectionItems={records} sectionItems={sectionItems}
filterBuilderProps={FILTER_BUILDER} filterBuilderProps={filterBuilderProps}
customFilterType="queue" customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter} dispatchSetFilter={dispatchSetFilter}
/> />
); );

View File

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

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import { useSelect } from 'App/Select/SelectContext'; 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';
@@ -14,17 +15,20 @@ 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 { useEpisodesWithIds } from 'Episode/useEpisode'; import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
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 { useSingleSeries } from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { useUiSettingsValues } from 'Settings/UI/useUiSettings'; import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
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';
import Queue, { import {
QueueTrackedDownloadState, QueueTrackedDownloadState,
QueueTrackedDownloadStatus, QueueTrackedDownloadStatus,
StatusMessage, StatusMessage,
@@ -32,19 +36,16 @@ import Queue, {
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 from './RemoveQueueItemModal'; import RemoveQueueItemModal, { RemovePressProps } 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;
episodeIds: number[]; episodeId?: number;
downloadId: string; downloadId?: string;
title: string; title: string;
status: string; status: string;
trackedDownloadStatus?: QueueTrackedDownloadStatus; trackedDownloadStatus?: QueueTrackedDownloadStatus;
@@ -57,18 +58,20 @@ 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;
columns: Column[]; columns: Column[];
onSelectedChange: (options: SelectStateInputProps) => void;
onQueueRowModalOpenOrClose: (isOpen: boolean) => void; onQueueRowModalOpenOrClose: (isOpen: boolean) => void;
} }
@@ -76,7 +79,7 @@ function QueueRow(props: QueueRowProps) {
const { const {
id, id,
seriesId, seriesId,
episodeIds, episodeId,
downloadId, downloadId,
title, title,
status, status,
@@ -94,24 +97,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,
columns, columns,
onSelectedChange,
onQueueRowModalOpenOrClose, onQueueRowModalOpenOrClose,
} = props; } = props;
const series = useSingleSeries(seriesId); const dispatch = useDispatch();
const episodes = useEpisodesWithIds(episodeIds); const series = useSeries(seriesId);
const { showRelativeDates, shortDateFormat, timeFormat } = const episode = useEpisode(episodeId, 'episodes');
useUiSettingsValues(); const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id); createUISettingsSelector()
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id); );
const { toggleSelected, useIsSelected } = useSelect<Queue>();
const isSelected = useIsSelected(id);
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] = const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
useState(false); useState(false);
@@ -120,8 +124,8 @@ function QueueRow(props: QueueRowProps) {
useState(false); useState(false);
const handleGrabPress = useCallback(() => { const handleGrabPress = useCallback(() => {
grabQueueItem(); dispatch(grabQueueItem({ id }));
}, [grabQueueItem]); }, [id, dispatch]);
const handleInteractiveImportPress = useCallback(() => { const handleInteractiveImportPress = useCallback(() => {
onQueueRowModalOpenOrClose(true); onQueueRowModalOpenOrClose(true);
@@ -138,33 +142,21 @@ function QueueRow(props: QueueRowProps) {
setIsRemoveQueueItemModalOpen(true); setIsRemoveQueueItemModalOpen(true);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const handleRemoveQueueItemModalConfirmed = useCallback(() => { const handleRemoveQueueItemModalConfirmed = useCallback(
onQueueRowModalOpenOrClose(false); (payload: RemovePressProps) => {
removeQueueItem(); onQueueRowModalOpenOrClose(false);
setIsRemoveQueueItemModalOpen(false); dispatch(removeQueueItem({ id, ...payload }));
}, [ setIsRemoveQueueItemModalOpen(false);
setIsRemoveQueueItemModalOpen, },
removeQueueItem, [id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch]
onQueueRowModalOpenOrClose, );
]);
const handleRemoveQueueItemModalClose = useCallback(() => { const handleRemoveQueueItemModalClose = useCallback(() => {
onQueueRowModalOpenOrClose(false); onQueueRowModalOpenOrClose(false);
setIsRemoveQueueItemModalOpen(false); setIsRemoveQueueItemModalOpen(false);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const handleSelectedChange = useCallback( const progress = 100 - (sizeleft / size) * 100;
({ id, value, shiftKey = false }: SelectStateInputProps) => {
toggleSelected({
id,
isSelected: value,
shiftKey,
});
},
[toggleSelected]
);
const progress = 100 - (sizeLeft / size) * 100;
const showInteractiveImport = const showInteractiveImport =
status === 'completed' && trackedDownloadStatus === 'warning'; status === 'completed' && trackedDownloadStatus === 'warning';
const isPending = const isPending =
@@ -175,7 +167,7 @@ function QueueRow(props: QueueRowProps) {
<TableSelectCell <TableSelectCell
id={id} id={id}
isSelected={isSelected} isSelected={isSelected}
onSelectedChange={handleSelectedChange} onSelectedChange={onSelectedChange}
/> />
{columns.map((column) => { {columns.map((column) => {
@@ -217,12 +209,23 @@ function QueueRow(props: QueueRowProps) {
if (name === 'episode') { if (name === 'episode') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
<EpisodeCellContent {episode ? (
episodes={episodes} <SeasonEpisodeNumber
isFullSeason={isFullSeason} seasonNumber={episode.seasonNumber}
seasonNumber={seasonNumbers[0]} episodeNumber={episode.episodeNumber}
series={series} absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
/> seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={
episode.sceneAbsoluteEpisodeNumber
}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/>
) : (
'-'
)}
</TableRowCell> </TableRowCell>
); );
} }
@@ -230,37 +233,27 @@ function QueueRow(props: QueueRowProps) {
if (name === 'episodes.title') { if (name === 'episodes.title') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
<EpisodeTitleCellContent episodes={episodes} series={series} /> {series && episode ? (
<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 (episodes.length === 0) { if (episode) {
return <TableRowCell key={name}>-</TableRowCell>; return <RelativeDateCell key={name} date={episode.airDateUtc} />;
} }
if (episodes.length === 1) { return <TableRowCell key={name}>-</TableRowCell>;
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') {
@@ -332,13 +325,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}
@@ -397,8 +390,8 @@ function QueueRow(props: QueueRowProps) {
<InteractiveImportModal <InteractiveImportModal
isOpen={isInteractiveImportModalOpen} isOpen={isInteractiveImportModalOpen}
downloadIds={[downloadId]} downloadId={downloadId}
title={title} modalTitle={title}
onModalClose={handleInteractiveImportModalClose} onModalClose={handleInteractiveImportModalClose}
/> />

View File

@@ -90,7 +90,7 @@ function QueueStatus(props: QueueStatusProps) {
if (trackedDownloadState === 'importing') { if (trackedDownloadState === 'importing') {
title += ` - ${translate('Importing')}`; title += ` - ${translate('Importing')}`;
iconKind = kinds.PRIMARY; iconKind = kinds.PURPLE;
} }
if (trackedDownloadState === 'failedPending') { if (trackedDownloadState === 'failedPending') {

View File

@@ -1,4 +1,6 @@
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';
@@ -9,16 +11,19 @@ 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;
@@ -26,7 +31,7 @@ interface RemoveQueueItemModalProps {
canIgnore: boolean; canIgnore: boolean;
isPending: boolean; isPending: boolean;
selectedCount?: number; selectedCount?: number;
onRemovePress(): void; onRemovePress(props: RemovePressProps): void;
onModalClose: () => void; onModalClose: () => void;
} }
@@ -42,8 +47,13 @@ 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) {
@@ -128,19 +138,20 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
}, [isPending, multipleSelected]); }, [isPending, multipleSelected]);
const handleRemovalOptionInputChange = useCallback( const handleRemovalOptionInputChange = useCallback(
({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => { ({ name, value }: InputChanged) => {
setQueueOption('removalOptions', { dispatch(setQueueRemovalOption({ [name]: value }));
removalMethod,
blocklistMethod,
[name]: value,
});
}, },
[removalMethod, blocklistMethod] [dispatch]
); );
const handleConfirmRemove = useCallback(() => { const handleConfirmRemove = useCallback(() => {
onRemovePress(); onRemovePress({
}, [onRemovePress]); remove: removalMethod === 'removeFromClient',
changeCategory: removalMethod === 'changeCategory',
blocklist: blocklistMethod !== 'doNotBlocklist',
skipRedownload: blocklistMethod === 'blocklistOnly',
});
}, [removalMethod, blocklistMethod, onRemovePress]);
const handleModalClose = useCallback(() => { const handleModalClose = useCallback(() => {
onModalClose(); onModalClose();
@@ -167,7 +178,6 @@ 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>
@@ -186,7 +196,6 @@ 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>

View File

@@ -1,22 +1,36 @@
import React from 'react'; import React, { useEffect } 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 translate from 'Utilities/String/translate'; 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 { errors, warnings, count } = useQueueStatus(); const dispatch = useDispatch();
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 <PageSidebarStatus count={count} errors={errors} warnings={warnings} />
aria-label={
count === 1
? translate('QueueItem')
: translate('QueueItems', { count })
}
count={count}
errors={errors}
warnings={warnings}
/>
); );
} }

View File

@@ -0,0 +1,32 @@
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;

View File

@@ -1,33 +0,0 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export default function useQueueStatus() {
const { data } = useApiQuery<QueueStatus>({
path: '/queue/status',
});
if (!data) {
return {
count: 0,
errors: false,
warnings: false,
};
}
const { errors, warnings, unknownErrors, unknownWarnings, totalCount } = data;
return {
count: totalCount,
errors: errors || unknownErrors,
warnings: warnings || unknownWarnings,
};
}

View File

@@ -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;

View File

@@ -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 {
'warning': string; 'timeleft': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;
export default cssExports; export default cssExports;

View File

@@ -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;

View File

@@ -1,159 +0,0 @@
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 {
removalOptions: QueueRemovalOptions;
}
const { useOptions, useOption, setOptions, setOption, setSort } =
createOptionsStore<QueueOptions>('queue_options', () => {
return {
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;
export const setQueueSort = setSort;

View File

@@ -1,209 +0,0 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { useCustomFiltersList } from 'Filters/useCustomFilters';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
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: [],
},
{
key: 'excludeUnknownSeriesItems',
label: () => translate('ExcludeUnknownSeriesItems'),
filters: [
{
key: 'includeUnknownSeriesItems',
value: [false],
type: 'equal',
},
],
},
];
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,
},
{
name: 'includeUnknownSeriesItems',
label: () => translate('UnknownSeriesItems'),
type: 'equal',
valueType: filterBuilderValueTypes.BOOL,
},
];
const useQueue = () => {
const { page, goToPage } = usePage('queue');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useQueueOptions();
const customFilters = useCustomFiltersList('queue');
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<Queue>({
path: '/queue',
page,
pageSize,
filters,
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,
};
};

View File

@@ -1,4 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput'; import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@@ -10,7 +12,6 @@ import PageContentBody from 'Components/Page/PageContentBody';
import useDebounce from 'Helpers/Hooks/useDebounce'; import useDebounce from 'Helpers/Hooks/useDebounce';
import useQueryParams from 'Helpers/Hooks/useQueryParams'; import useQueryParams from 'Helpers/Hooks/useQueryParams';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import { useHasSeries } from 'Series/useSeries';
import { InputChanged } from 'typings/inputs'; import { InputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage'; import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
@@ -20,7 +21,11 @@ import styles from './AddNewSeries.css';
function AddNewSeries() { function AddNewSeries() {
const { term: initialTerm = '' } = useQueryParams<{ term: string }>(); const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
const hasSeries = useHasSeries();
const seriesCount = useSelector(
(state: AppState) => state.series.items.length
);
const [term, setTerm] = useState(initialTerm); const [term, setTerm] = useState(initialTerm);
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const query = useDebounce(term, term ? 300 : 0); const query = useDebounce(term, term ? 300 : 0);
@@ -38,7 +43,11 @@ function AddNewSeries() {
setIsFetching(false); setIsFetching(false);
}, []); }, []);
const { isFetching: isFetchingApi, error, data } = useLookupSeries(query); const {
isFetching: isFetchingApi,
error,
data = [],
} = useLookupSeries(query);
useEffect(() => { useEffect(() => {
setIsFetching(isFetchingApi); setIsFetching(isFetchingApi);
@@ -118,7 +127,7 @@ function AddNewSeries() {
</div> </div>
)} )}
{!term && !hasSeries ? ( {!term && !seriesCount ? (
<div className={styles.message}> <div className={styles.message}>
<div className={styles.noSeriesText}> <div className={styles.noSeriesText}>
{translate('NoSeriesHaveBeenAdded')} {translate('NoSeriesHaveBeenAdded')}

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries'; import AddSeries from 'AddSeries/AddSeries';
import { import {
AddSeriesOptions, AddSeriesOptions,
@@ -7,7 +8,6 @@ import {
} from 'AddSeries/addSeriesOptionsStore'; } from 'AddSeries/addSeriesOptionsStore';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent'; import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent'; import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
import { useAppDimension } from 'App/appStore';
import CheckInput from 'Components/Form/CheckInput'; import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form'; import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
@@ -20,12 +20,12 @@ 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 Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import { getValidationFailures } from 'Helpers/Hooks/useApiMutation';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesType } from 'Series/Series'; import { SeriesType } from 'Series/Series';
import SeriesPoster from 'Series/SeriesPoster'; import SeriesPoster from 'Series/SeriesPoster';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings'; import selectSettings from 'Store/Selectors/selectSettings';
import { useIsWindows } from 'System/Status/useSystemStatus'; import useIsWindows from 'System/useIsWindows';
import { InputChanged } from 'typings/inputs'; import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import { useAddSeries } from './useAddSeries'; import { useAddSeries } from './useAddSeries';
@@ -44,16 +44,17 @@ function AddNewSeriesModalContent({
}: AddNewSeriesModalContentProps) { }: AddNewSeriesModalContentProps) {
const { title, year, overview, images, folder } = series; const { title, year, overview, images, folder } = series;
const options = useAddSeriesOptions(); const options = useAddSeriesOptions();
const isSmallScreen = useAppDimension('isSmallScreen'); const { isSmallScreen } = useSelector(createDimensionsSelector());
const isWindows = useIsWindows(); const isWindows = useIsWindows();
const { isAdding, addError, addSeries } = useAddSeries(); const {
isPending: isAdding,
error: addError,
mutate: addSeries,
} = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => { const { settings, validationErrors, validationWarnings } = useMemo(() => {
return { return selectSettings(options, {}, addError);
...selectSettings(options, {}),
...getValidationFailures(addError),
};
}, [options, addError]); }, [options, addError]);
const [seriesType, setSeriesType] = useState<SeriesType>( const [seriesType, setSeriesType] = useState<SeriesType>(
@@ -91,14 +92,12 @@ function AddNewSeriesModalContent({
addSeries({ addSeries({
...series, ...series,
rootFolderPath: rootFolderPath.value, rootFolderPath: rootFolderPath.value,
addOptions: { monitor: monitor.value,
monitor: monitor.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
},
qualityProfileId: qualityProfileId.value, qualityProfileId: qualityProfileId.value,
seriesType, seriesType,
seasonFolder: seasonFolder.value, seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value, tags: tags.value,
}); });
}, [ }, [
@@ -136,7 +135,6 @@ function AddNewSeriesModalContent({
className={styles.poster} className={styles.poster}
images={images} images={images}
size={250} size={250}
title={title}
/> />
</div> </div>
)} )}

View File

@@ -97,12 +97,6 @@
pointer-events: all; pointer-events: all;
} }
.excludedIcon {
margin-left: 10px;
color: var(--dangerColor);
pointer-events: all;
}
.overview { .overview {
margin-top: 20px; margin-top: 20px;
} }

View File

@@ -3,7 +3,6 @@
interface CssExports { interface CssExports {
'alreadyExistsIcon': string; 'alreadyExistsIcon': string;
'content': string; 'content': string;
'excludedIcon': string;
'genres': string; 'genres': string;
'icons': string; 'icons': string;
'network': string; 'network': string;

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries'; import AddSeries from 'AddSeries/AddSeries';
import { useAppDimension } from 'App/appStore';
import HeartRating from 'Components/HeartRating'; import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Label from 'Components/Label'; import Label from 'Components/Label';
@@ -10,7 +10,8 @@ import { icons, kinds, sizes } from 'Helpers/Props';
import { Statistics } from 'Series/Series'; import { Statistics } from 'Series/Series';
import SeriesGenres from 'Series/SeriesGenres'; import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster'; import SeriesPoster from 'Series/SeriesPoster';
import useExistingSeries from 'Series/useExistingSeries'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal'; import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css'; import styles from './AddNewSeriesSearchResult.css';
@@ -34,11 +35,10 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
overview, overview,
seriesType, seriesType,
images, images,
isExcluded,
} = series; } = series;
const isExistingSeries = useExistingSeries(tvdbId); const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const isSmallScreen = useAppDimension('isSmallScreen'); const { isSmallScreen } = useSelector(createDimensionsSelector());
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false); const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
const seasonCount = statistics.seasonCount; const seasonCount = statistics.seasonCount;
@@ -75,7 +75,6 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
size={250} size={250}
overflow={true} overflow={true}
lazy={false} lazy={false}
title={title}
/> />
)} )}
@@ -101,15 +100,6 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
/> />
) : null} ) : null}
{isExcluded ? (
<Icon
className={styles.excludedIcon}
name={icons.DANGER}
size={36}
title={translate('SeriesInImportListExclusions')}
/>
) : null}
<Link <Link
className={styles.tvdbLink} className={styles.tvdbLink}
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`} to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}

View File

@@ -1,62 +1,43 @@
import { useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries'; import AddSeries from 'AddSeries/AddSeries';
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore'; import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import useApiMutation from 'Helpers/Hooks/useApiMutation'; import useApiMutation from 'Helpers/Hooks/useApiMutation';
import useApiQuery from 'Helpers/Hooks/useApiQuery'; import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Series from 'Series/Series'; import Series from 'Series/Series';
import { updateItem } from 'Store/Actions/baseActions';
interface AddSeriesPayload type AddSeriesPayload = AddSeries & AddSeriesOptions;
extends AddSeries,
Omit<
AddSeriesOptions,
'monitor' | 'searchForMissingEpisodes' | 'searchForCutoffUnmetEpisodes'
> {}
const DEFAULT_SERIES: AddSeries[] = []; export const useLookupSeries = (query: string) => {
return useApiQuery<AddSeries[]>({
export const useLookupSeries = (query: string, isEnabled = true) => {
const result = useApiQuery<AddSeries[]>({
path: '/series/lookup', path: '/series/lookup',
queryParams: { queryParams: {
term: query, term: query,
}, },
queryOptions: { queryOptions: {
enabled: isEnabled && !!query, enabled: !!query,
// Disable refetch on window focus to prevent refetching when the user switch tabs // Disable refetch on window focus to prevent refetching when the user switch tabs
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, },
}); });
return {
...result,
data: result.data ?? DEFAULT_SERIES,
};
}; };
export const useAddSeries = () => { export const useAddSeries = () => {
const queryClient = useQueryClient(); const dispatch = useDispatch();
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>( const onAddSuccess = useCallback(
{ (data: Series) => {
path: '/series', dispatch(updateItem({ section: 'series', ...data }));
method: 'POST', },
mutationOptions: { [dispatch]
onSuccess: (newSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return [newSeries];
}
return [...oldSeries, newSeries];
});
},
},
}
); );
return { return useApiMutation<Series, AddSeriesPayload>({
isAdding: isPending, path: '/series',
addError: error, method: 'POST',
addSeries: mutate, mutationOptions: {
}; onSuccess: onAddSuccess,
},
});
}; };

View File

@@ -2,7 +2,6 @@ import Series from 'Series/Series';
interface AddSeries extends Series { interface AddSeries extends Series {
folder: string; folder: string;
isExcluded: boolean;
} }
export default AddSeries; export default AddSeries;

View File

@@ -1,23 +1,25 @@
import React, { useEffect, useMemo, useRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { import {
setAddSeriesOption, setAddSeriesOption,
useAddSeriesOption, useAddSeriesOption,
} from 'AddSeries/addSeriesOptionsStore'; } from 'AddSeries/addSeriesOptionsStore';
import { SelectProvider } from 'App/Select/SelectContext'; import { SelectProvider } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody'; import PageContentBody from 'Components/Page/PageContentBody';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import useRootFolders, { useRootFolder } from 'RootFolder/useRootFolders'; import { clearImportSeries } from 'Store/Actions/importSeriesActions';
import { useQualityProfilesData } from 'Settings/Profiles/Quality/useQualityProfiles'; import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import ImportSeriesFooter from './ImportSeriesFooter'; import ImportSeriesFooter from './ImportSeriesFooter';
import { clearImportSeries } from './importSeriesStore';
import ImportSeriesTable from './ImportSeriesTable'; import ImportSeriesTable from './ImportSeriesTable';
function ImportSeries() { function ImportSeries() {
const dispatch = useDispatch();
const { rootFolderId: rootFolderIdString } = useParams<{ const { rootFolderId: rootFolderIdString } = useParams<{
rootFolderId: string; rootFolderId: string;
}>(); }>();
@@ -25,12 +27,10 @@ function ImportSeries() {
const { const {
isFetching: rootFoldersFetching, isFetching: rootFoldersFetching,
isFetched: rootFoldersFetched, isPopulated: rootFoldersPopulated,
error: rootFoldersError, error: rootFoldersError,
data: rootFolders, items: rootFolders,
} = useRootFolders(); } = useSelector((state: AppState) => state.rootFolders);
useRootFolder(rootFolderId, false);
const { path, unmappedFolders } = useMemo(() => { const { path, unmappedFolders } = useMemo(() => {
const rootFolder = rootFolders.find((r) => r.id === rootFolderId); const rootFolder = rootFolders.find((r) => r.id === rootFolderId);
@@ -47,7 +47,9 @@ function ImportSeries() {
}; };
}, [rootFolders, rootFolderId]); }, [rootFolders, rootFolderId]);
const qualityProfiles = useQualityProfilesData(); const qualityProfiles = useSelector(
(state: AppState) => state.settings.qualityProfiles.items
);
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId'); const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
@@ -63,10 +65,12 @@ function ImportSeries() {
}, [unmappedFolders]); }, [unmappedFolders]);
useEffect(() => { useEffect(() => {
dispatch(fetchRootFolders({ id: rootFolderId, timeout: false }));
return () => { return () => {
clearImportSeries(); dispatch(clearImportSeries());
}; };
}, [rootFolderId]); }, [rootFolderId, dispatch]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -75,15 +79,13 @@ function ImportSeries() {
) { ) {
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id); setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
} }
}, [defaultQualityProfileId, qualityProfiles]); }, [defaultQualityProfileId, qualityProfiles, dispatch]);
return ( return (
<SelectProvider items={items}> <SelectProvider items={items}>
<PageContent title={translate('ImportSeries')}> <PageContent title={translate('ImportSeries')}>
<PageContentBody ref={scrollerRef}> <PageContentBody ref={scrollerRef}>
{rootFoldersFetching && !rootFoldersFetched ? ( {rootFoldersFetching ? <LoadingIndicator /> : null}
<LoadingIndicator />
) : null}
{!rootFoldersFetching && !!rootFoldersError ? ( {!rootFoldersFetching && !!rootFoldersError ? (
<Alert kind={kinds.DANGER}> <Alert kind={kinds.DANGER}>
@@ -93,7 +95,7 @@ function ImportSeries() {
{!rootFoldersError && {!rootFoldersError &&
!rootFoldersFetching && !rootFoldersFetching &&
rootFoldersFetched && rootFoldersPopulated &&
!unmappedFolders.length ? ( !unmappedFolders.length ? (
<Alert kind={kinds.INFO}> <Alert kind={kinds.INFO}>
{translate('AllSeriesInRootFolderHaveBeenImported', { path })} {translate('AllSeriesInRootFolderHaveBeenImported', { path })}
@@ -101,14 +103,20 @@ function ImportSeries() {
) : null} ) : null}
{!rootFoldersError && {!rootFoldersError &&
rootFoldersFetched && !rootFoldersFetching &&
rootFoldersPopulated &&
!!unmappedFolders.length && !!unmappedFolders.length &&
scrollerRef.current ? ( scrollerRef.current ? (
<ImportSeriesTable items={items} scrollerRef={scrollerRef} /> <ImportSeriesTable
unmappedFolders={unmappedFolders}
scrollerRef={scrollerRef}
/>
) : null} ) : null}
</PageContentBody> </PageContentBody>
{!rootFoldersError && rootFoldersFetched && !!unmappedFolders.length ? ( {!rootFoldersError &&
!rootFoldersFetching &&
!!unmappedFolders.length ? (
<ImportSeriesFooter /> <ImportSeriesFooter />
) : null} ) : null}
</PageContent> </PageContent>

View File

@@ -1,10 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { import {
AddSeriesOptions, AddSeriesOptions,
setAddSeriesOption, setAddSeriesOption,
useAddSeriesOptions, useAddSeriesOptions,
} from 'AddSeries/addSeriesOptionsStore'; } from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/Select/SelectContext'; import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import CheckInput from 'Components/Form/CheckInput'; import CheckInput from 'Components/Form/CheckInput';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@@ -15,22 +17,21 @@ import PageContentFooter from 'Components/Page/PageContentFooter';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesMonitor, SeriesType } from 'Series/Series'; import { SeriesMonitor, SeriesType } from 'Series/Series';
import {
cancelLookupSeries,
importSeries,
lookupUnsearchedSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import { InputChanged } from 'typings/inputs'; import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import { import getSelectedIds from 'Utilities/Table/getSelectedIds';
ImportSeriesItem,
startProcessing,
stopProcessing,
updateImportSeriesItem,
useImportSeriesItems,
useLookupQueueHasItems,
} from './importSeriesStore';
import { useImportSeries } from './useImportSeries';
import styles from './ImportSeriesFooter.css'; import styles from './ImportSeriesFooter.css';
type MixedType = 'mixed'; type MixedType = 'mixed';
function ImportSeriesFooter() { function ImportSeriesFooter() {
const dispatch = useDispatch();
const { const {
monitor: defaultMonitor, monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId, qualityProfileId: defaultQualityProfileId,
@@ -38,8 +39,9 @@ function ImportSeriesFooter() {
seasonFolder: defaultSeasonFolder, seasonFolder: defaultSeasonFolder,
} = useAddSeriesOptions(); } = useAddSeriesOptions();
const items = useImportSeriesItems(); const { isLookingUpSeries, isImporting, items, importError } = useSelector(
const isLookingUpSeries = useLookupQueueHasItems(); (state: AppState) => state.importSeries
);
const [monitor, setMonitor] = useState<SeriesMonitor | MixedType>( const [monitor, setMonitor] = useState<SeriesMonitor | MixedType>(
defaultMonitor defaultMonitor
@@ -54,9 +56,11 @@ function ImportSeriesFooter() {
defaultSeasonFolder defaultSeasonFolder
); );
const { selectedCount, getSelectedIds } = useSelect<ImportSeriesItem>(); const [selectState] = useSelect();
const { importSeries, isImporting, importError } = useImportSeries(); const selectedIds = useMemo(() => {
return getSelectedIds(selectState.selectedState, (id) => id);
}, [selectState.selectedState]);
const { const {
hasUnsearchedItems, hasUnsearchedItems,
@@ -88,7 +92,7 @@ function ImportSeriesFooter() {
isSeasonFolderMixed = true; isSeasonFolderMixed = true;
} }
if (!item.hasSearched) { if (!item.isPopulated) {
hasUnsearchedItems = true; hasUnsearchedItems = true;
} }
}); });
@@ -123,27 +127,30 @@ function ImportSeriesFooter() {
setAddSeriesOption(name as keyof AddSeriesOptions, value); setAddSeriesOption(name as keyof AddSeriesOptions, value);
getSelectedIds().forEach((id) => { selectedIds.forEach((id) => {
updateImportSeriesItem({ dispatch(
id, // @ts-expect-error - actions are not typed
[name]: value, setImportSeriesValue({
}); id,
[name]: value,
})
);
}); });
}, },
[getSelectedIds] [selectedIds, dispatch]
); );
const handleLookupPress = useCallback(() => { const handleLookupPress = useCallback(() => {
startProcessing(); dispatch(lookupUnsearchedSeries());
}, []); }, [dispatch]);
const handleCancelLookupPress = useCallback(() => { const handleCancelLookupPress = useCallback(() => {
stopProcessing(); dispatch(cancelLookupSeries());
}, []); }, [dispatch]);
const handleImportPress = useCallback(() => { const handleImportPress = useCallback(() => {
importSeries(getSelectedIds()); dispatch(importSeries({ ids: selectedIds }));
}, [importSeries, getSelectedIds]); }, [selectedIds, dispatch]);
useEffect(() => { useEffect(() => {
if (isMonitorMixed && monitor !== 'mixed') { if (isMonitorMixed && monitor !== 'mixed') {
@@ -180,6 +187,8 @@ function ImportSeriesFooter() {
} }
}, [defaultSeasonFolder, isSeasonFolderMixed, seasonFolder]); }, [defaultSeasonFolder, isSeasonFolderMixed, seasonFolder]);
const selectedCount = selectedIds.length;
return ( return (
<PageContentFooter> <PageContentFooter>
<div className={styles.inputContainer}> <div className={styles.inputContainer}>
@@ -284,12 +293,12 @@ function ImportSeriesFooter() {
title={translate('ImportErrors')} title={translate('ImportErrors')}
body={ body={
<ul> <ul>
{Array.isArray(importError.statusBody) ? ( {Array.isArray(importError.responseJSON) ? (
importError.statusBody.map((error, index) => { importError.responseJSON.map((error, index) => {
return <li key={index}>{error.errorMessage}</li>; return <li key={index}>{error.errorMessage}</li>;
}) })
) : ( ) : (
<li>{JSON.stringify(importError.statusBody)}</li> <li>{JSON.stringify(importError.responseJSON)}</li>
)} )}
</ul> </ul>
} }

View File

@@ -1,29 +1,39 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback } from 'react';
import { useSelect } from 'App/Select/SelectContext'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import { inputTypes } from 'Helpers/Props'; import { inputTypes } from 'Helpers/Props';
import useExistingSeries from 'Series/useExistingSeries'; import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import { InputChanged } from 'typings/inputs'; import { InputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props'; import { SelectStateInputProps } from 'typings/props';
import {
ImportSeriesItem,
UnamppedFolderItem,
updateImportSeriesItem,
useImportSeriesItem,
} from './importSeriesStore';
import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries'; import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries';
import styles from './ImportSeriesRow.css'; import styles from './ImportSeriesRow.css';
interface ImportSeriesRowProps { function createItemSelector(id: string) {
unmappedFolder: UnamppedFolderItem; return createSelector(
(state: AppState) => state.importSeries.items,
(items) => {
return (
items.find((item) => {
return item.id === id;
}) || ({} as ImportSeries)
);
}
);
} }
function ImportSeriesRow({ unmappedFolder }: ImportSeriesRowProps) { interface ImportSeriesRowProps {
const id = unmappedFolder.id; id: string;
}
const item = useImportSeriesItem(unmappedFolder.id); function ImportSeriesRow({ id }: ImportSeriesRowProps) {
const dispatch = useDispatch();
const { const {
relativePath, relativePath,
@@ -32,45 +42,45 @@ function ImportSeriesRow({ unmappedFolder }: ImportSeriesRowProps) {
seasonFolder, seasonFolder,
seriesType, seriesType,
selectedSeries, selectedSeries,
} = item ?? {}; } = useSelector(createItemSelector(id));
const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId); const isExistingSeries = useSelector(
createExistingSeriesSelector(selectedSeries?.tvdbId)
);
const { getIsSelected, toggleSelected, toggleDisabled } = const [selectState, selectDispatch] = useSelect();
useSelect<ImportSeriesItem>();
const handleInputChange = useCallback( const handleInputChange = useCallback(
({ name, value }: InputChanged) => { ({ name, value }: InputChanged) => {
updateImportSeriesItem({ id, [name]: value }); dispatch(
// @ts-expect-error - actions are not typed
setImportSeriesValue({
id,
[name]: value,
})
);
}, },
[id] [id, dispatch]
); );
const handleSelectedChange = useCallback( const handleSelectedChange = useCallback(
({ id, value, shiftKey }: SelectStateInputProps<string>) => { ({ id, value, shiftKey }: SelectStateInputProps) => {
toggleSelected({ selectDispatch({
type: 'toggleSelected',
id, id,
isSelected: value, isSelected: value,
shiftKey, shiftKey,
}); });
}, },
[toggleSelected] [selectDispatch]
); );
useEffect(() => {
toggleDisabled(id, !selectedSeries || isExistingSeries);
}, [id, selectedSeries, isExistingSeries, toggleDisabled]);
useEffect(() => {
toggleSelected({ id, isSelected: !!selectedSeries, shiftKey: false });
}, [id, selectedSeries, toggleSelected]);
return ( return (
<> <>
<VirtualTableSelectCell<string> <VirtualTableSelectCell
inputClassName={styles.selectInput} inputClassName={styles.selectInput}
id={id} id={id}
isSelected={getIsSelected(id)} isSelected={selectState.selectedState[id]}
isDisabled={!selectedSeries || isExistingSeries} isDisabled={!selectedSeries || isExistingSeries}
onSelectedChange={handleSelectedChange} onSelectedChange={handleSelectedChange}
/> />

View File

@@ -1,25 +1,33 @@
import React, { RefObject, useCallback, useRef } from 'react'; import React, { RefObject, useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { FixedSizeList, ListChildComponentProps } from 'react-window'; import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useAppDimension } from 'App/appStore'; import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/Select/SelectContext'; import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
import VirtualTable from 'Components/Table/VirtualTable'; import VirtualTable from 'Components/Table/VirtualTable';
import usePrevious from 'Helpers/Hooks/usePrevious';
import {
queueLookupSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { CheckInputChanged } from 'typings/inputs'; import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import { UnmappedFolder } from 'typings/RootFolder';
import ImportSeriesHeader from './ImportSeriesHeader'; import ImportSeriesHeader from './ImportSeriesHeader';
import ImportSeriesRow from './ImportSeriesRow'; import ImportSeriesRow from './ImportSeriesRow';
import {
UnamppedFolderItem,
useEnsureImportSeriesItems,
} from './importSeriesStore';
import styles from './ImportSeriesTable.css'; import styles from './ImportSeriesTable.css';
const ROW_HEIGHT = 52; const ROW_HEIGHT = 52;
interface RowItemData { interface RowItemData {
items: UnamppedFolderItem[]; items: ImportSeries[];
} }
interface ImportSeriesTableProps { interface ImportSeriesTableProps {
items: UnamppedFolderItem[]; unmappedFolders: UnmappedFolder[];
scrollerRef: RefObject<HTMLElement>; scrollerRef: RefObject<HTMLElement>;
} }
@@ -41,34 +49,138 @@ function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
}} }}
className={styles.row} className={styles.row}
> >
<ImportSeriesRow key={item.id} unmappedFolder={item} /> <ImportSeriesRow key={item.id} id={item.id} />
</div> </div>
); );
} }
function ImportSeriesTable({ items, scrollerRef }: ImportSeriesTableProps) { function ImportSeriesTable({
const isSmallScreen = useAppDimension('isSmallScreen'); unmappedFolders,
const { allSelected, allUnselected, selectAll, unselectAll, useHasItems } = scrollerRef,
useSelect(); }: ImportSeriesTableProps) {
const dispatch = useDispatch();
const { monitor, qualityProfileId, seriesType, seasonFolder } =
useAddSeriesOptions();
const items = useSelector((state: AppState) => state.importSeries.items);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const allSeries = useSelector(createAllSeriesSelector());
const [selectState, selectDispatch] = useSelect();
const defaultValues = useRef({
monitor,
qualityProfileId,
seriesType,
seasonFolder,
});
const listRef = useRef<FixedSizeList<RowItemData>>(null); const listRef = useRef<FixedSizeList<RowItemData>>(null);
const initialUnmappedFolders = useRef(unmappedFolders);
const previousItems = usePrevious(items);
const { allSelected, allUnselected, selectedState } = selectState;
const handleSelectAllChange = useCallback( const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => { ({ value }: CheckInputChanged) => {
if (value) { selectDispatch({
selectAll(); type: value ? 'selectAll' : 'unselectAll',
} else { });
unselectAll();
}
}, },
[selectAll, unselectAll] [selectDispatch]
); );
const hasSelectItems = useHasItems(); const handleSelectedChange = useCallback(
({ id, value, shiftKey }: SelectStateInputProps) => {
selectDispatch({
type: 'toggleSelected',
id,
isSelected: value,
shiftKey,
});
},
[selectDispatch]
);
useEnsureImportSeriesItems(items); const handleRemoveSelectedStateItem = useCallback(
(id: string) => {
selectDispatch({
type: 'removeItem',
id,
});
},
[selectDispatch]
);
if (!items.length || !hasSelectItems) { useEffect(() => {
initialUnmappedFolders.current.forEach(({ name, path, relativePath }) => {
dispatch(
queueLookupSeries({
name,
path,
relativePath,
term: name,
})
);
dispatch(
// @ts-expect-error - actions are not typed
setImportSeriesValue({
id: name,
...defaultValues.current,
})
);
});
}, [dispatch]);
useEffect(() => {
previousItems?.forEach((prevItem) => {
const { id } = prevItem;
const item = items.find((i) => i.id === id);
if (!item) {
handleRemoveSelectedStateItem(id);
return;
}
const selectedSeries = item.selectedSeries;
const isSelected = selectedState[id];
const isExistingSeries =
!!selectedSeries &&
allSeries.some((s) => s.tvdbId === selectedSeries.tvdbId);
if (
(!selectedSeries && prevItem.selectedSeries) ||
(isExistingSeries && !prevItem.selectedSeries)
) {
handleSelectedChange({ id, value: false, shiftKey: false });
return;
}
if (isSelected && (!selectedSeries || isExistingSeries)) {
handleSelectedChange({ id, value: false, shiftKey: false });
return;
}
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
handleSelectedChange({ id, value: true, shiftKey: false });
return;
}
});
}, [
allSeries,
items,
previousItems,
selectedState,
handleRemoveSelectedStateItem,
handleSelectedChange,
]);
if (!items.length) {
return null; return null;
} }

View File

@@ -1,8 +1,9 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import useExistingSeries from 'Series/useExistingSeries'; import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import ImportSeriesTitle from './ImportSeriesTitle'; import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSearchResult.css'; import styles from './ImportSeriesSearchResult.css';
@@ -21,7 +22,7 @@ function ImportSeriesSearchResult({
network, network,
onPress, onPress,
}: ImportSeriesSearchResultProps) { }: ImportSeriesSearchResultProps) {
const isExistingSeries = useExistingSeries(tvdbId); const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
onPress(tvdbId); onPress(tvdbId);

View File

@@ -7,27 +7,23 @@ import {
useFloating, useFloating,
useInteractions, useInteractions,
} from '@floating-ui/react'; } from '@floating-ui/react';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLookupSeries } from 'AddSeries/AddNewSeries/useAddSeries'; import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormInputButton from 'Components/Form/FormInputButton'; import FormInputButton from 'Components/Form/FormInputButton';
import TextInput from 'Components/Form/TextInput'; import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useDebounce from 'Helpers/Hooks/useDebounce';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import useExistingSeries from 'Series/useExistingSeries'; import {
queueLookupSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
import { InputChanged } from 'typings/inputs'; import { InputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage'; import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import {
addToLookupQueue,
removeFromLookupQueue,
updateImportSeriesItem,
useImportSeriesItem,
useIsCurrentedItemQueued,
useIsCurrentLookupQueueItem,
} from '../importSeriesStore';
import ImportSeriesSearchResult from './ImportSeriesSearchResult'; import ImportSeriesSearchResult from './ImportSeriesSearchResult';
import ImportSeriesTitle from './ImportSeriesTitle'; import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSelectSeries.css'; import styles from './ImportSeriesSelectSeries.css';
@@ -41,23 +37,29 @@ function ImportSeriesSelectSeries({
id, id,
onInputChange, onInputChange,
}: ImportSeriesSelectSeriesProps) { }: ImportSeriesSelectSeriesProps) {
const importSeriesItem = useImportSeriesItem(id); const dispatch = useDispatch();
const { selectedSeries, name } = importSeriesItem ?? {}; const isLookingUpSeries = useSelector(
const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId); (state: AppState) => state.importSeries.isLookingUpSeries
const [term, setTerm] = useState(name);
const [isOpen, setIsOpen] = useState(false);
const query = useDebounce(term, term ? 300 : 0);
const isCurrentLookupQueueItem = useIsCurrentLookupQueueItem(id);
const isQueued = useIsCurrentedItemQueued(id);
const { isFetching, isFetched, error, data, refetch } = useLookupSeries(
query,
isCurrentLookupQueueItem
); );
const {
error,
isFetching = true,
isPopulated = false,
items = [],
isQueued = true,
selectedSeries,
isExistingSeries,
term: itemTerm,
// @ts-expect-error - ignoring this for now
} = useSelector(createImportSeriesItemSelector(id, { id }));
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();
const [term, setTerm] = useState('');
const [isOpen, setIsOpen] = useState(false);
const errorMessage = getErrorMessage(error); const errorMessage = getErrorMessage(error);
const isLookingUpSeries = isFetching || isQueued;
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
setIsOpen((prevIsOpen) => !prevIsOpen); setIsOpen((prevIsOpen) => !prevIsOpen);
@@ -65,26 +67,48 @@ function ImportSeriesSelectSeries({
const handleSearchInputChange = useCallback( const handleSearchInputChange = useCallback(
({ value }: InputChanged<string>) => { ({ value }: InputChanged<string>) => {
if (seriesLookupTimeout.current) {
clearTimeout(seriesLookupTimeout.current);
}
setTerm(value); setTerm(value);
addToLookupQueue(id);
seriesLookupTimeout.current = setTimeout(() => {
dispatch(
queueLookupSeries({
name: id,
term: value,
topOfQueue: true,
})
);
}, 200);
}, },
[id] [id, dispatch]
); );
const handleRefreshPress = useCallback(() => { const handleRefreshPress = useCallback(() => {
refetch(); dispatch(
}, [refetch]); queueLookupSeries({
name: id,
term,
topOfQueue: true,
})
);
}, [id, term, dispatch]);
const handleSeriesSelect = useCallback( const handleSeriesSelect = useCallback(
(tvdbId: number) => { (tvdbId: number) => {
setIsOpen(false); setIsOpen(false);
const selectedSeries = data.find((item) => item.tvdbId === tvdbId)!; const selectedSeries = items.find((item) => item.tvdbId === tvdbId)!;
updateImportSeriesItem({ dispatch(
id, // @ts-expect-error - actions are not typed
selectedSeries, setImportSeriesValue({
}); id,
selectedSeries,
})
);
if (selectedSeries.seriesType !== 'standard') { if (selectedSeries.seriesType !== 'standard') {
onInputChange({ onInputChange({
@@ -93,24 +117,12 @@ function ImportSeriesSelectSeries({
}); });
} }
}, },
[id, data, onInputChange] [id, items, dispatch, onInputChange]
); );
useEffect(() => { useEffect(() => {
if (isFetched) { setTerm(itemTerm);
updateImportSeriesItem({ }, [itemTerm]);
id,
hasSearched: isFetched,
selectedSeries: data[0],
});
removeFromLookupQueue(id);
}
}, [id, isFetched, data]);
useEffect(() => {
setTerm(name);
}, [name]);
const { refs, context, floatingStyles } = useFloating({ const { refs, context, floatingStyles } = useFloating({
middleware: [ middleware: [
@@ -137,11 +149,11 @@ function ImportSeriesSelectSeries({
<> <>
<div ref={refs.setReference} {...getReferenceProps()}> <div ref={refs.setReference} {...getReferenceProps()}>
<Link className={styles.button} component="div" onPress={handlePress}> <Link className={styles.button} component="div" onPress={handlePress}>
{isLookingUpSeries && isQueued && !isFetched ? ( {isLookingUpSeries && isQueued && !isPopulated ? (
<LoadingIndicator className={styles.loading} size={20} /> <LoadingIndicator className={styles.loading} size={20} />
) : null} ) : null}
{isFetched && selectedSeries && isExistingSeries ? ( {isPopulated && selectedSeries && isExistingSeries ? (
<Icon <Icon
className={styles.warningIcon} className={styles.warningIcon}
name={icons.WARNING} name={icons.WARNING}
@@ -149,7 +161,7 @@ function ImportSeriesSelectSeries({
/> />
) : null} ) : null}
{isFetched && selectedSeries ? ( {isPopulated && selectedSeries ? (
<ImportSeriesTitle <ImportSeriesTitle
title={selectedSeries.title} title={selectedSeries.title}
year={selectedSeries.year} year={selectedSeries.year}
@@ -158,7 +170,7 @@ function ImportSeriesSelectSeries({
/> />
) : null} ) : null}
{isFetched && !selectedSeries ? ( {isPopulated && !selectedSeries ? (
<div> <div>
<Icon <Icon
className={styles.warningIcon} className={styles.warningIcon}
@@ -188,7 +200,6 @@ function ImportSeriesSelectSeries({
</div> </div>
</Link> </Link>
</div> </div>
{isOpen ? ( {isOpen ? (
<FloatingPortal id="portal-root"> <FloatingPortal id="portal-root">
<div <div
@@ -223,7 +234,7 @@ function ImportSeriesSelectSeries({
</div> </div>
<div className={styles.results}> <div className={styles.results}>
{data.map((item) => { {items.map((item) => {
return ( return (
<ImportSeriesSearchResult <ImportSeriesSearchResult
key={item.tvdbId} key={item.tvdbId}

View File

@@ -1,175 +0,0 @@
import { useEffect } from 'react';
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import { UnmappedFolder } from 'RootFolder/useRootFolders';
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
export interface UnamppedFolderItem extends UnmappedFolder {
id: string;
}
export interface ImportSeriesItem {
id: string;
monitor: SeriesMonitor;
path: string;
qualityProfileId: number;
relativePath: string;
seasonFolder: boolean;
selectedSeries?: Series;
seriesType: SeriesType;
name: string;
hasSearched: boolean;
}
interface ImportSeriesState {
items: Record<string, ImportSeriesItem>;
lookupQueue: string[];
isProcessing: boolean;
}
const defaultState: ImportSeriesState = {
items: {},
lookupQueue: [],
isProcessing: false,
};
const importSeriesStore = create<ImportSeriesState>()(() => defaultState);
export const useEnsureImportSeriesItems = (
unmappedFolders: UnamppedFolderItem[]
) => {
const { monitor, qualityProfileId, seriesType, seasonFolder } =
useAddSeriesOptions();
useEffect(() => {
unmappedFolders.forEach((unmappedFolder) => {
const existingItem =
importSeriesStore.getState().items[unmappedFolder.id];
if (existingItem) {
return;
}
const newItem: ImportSeriesItem = {
...unmappedFolder,
monitor,
qualityProfileId,
seriesType,
seasonFolder,
hasSearched: false,
};
importSeriesStore.setState((state) => ({
items: {
...state.items,
[unmappedFolder.id]: newItem,
},
}));
});
}, [unmappedFolders, monitor, qualityProfileId, seriesType, seasonFolder]);
};
export const updateImportSeriesItem = (
itemData: Partial<ImportSeriesItem> & Pick<ImportSeriesItem, 'id'>
) => {
importSeriesStore.setState((state) => {
const existingItem = state.items[itemData.id];
if (existingItem) {
return {
items: {
...state.items,
[itemData.id]: {
...existingItem,
...itemData,
},
},
};
}
return state;
});
};
export const removeImportSeriesItemByPath = (path: string) => {
importSeriesStore.setState((state) => {
const item = Object.values(state.items).find((i) => i.path === path);
if (!item) {
return state;
}
const { [item.id]: removed, ...items } = state.items;
return { items };
});
};
export const clearImportSeries = () => {
importSeriesStore.setState(defaultState);
};
export const startProcessing = () => {
importSeriesStore.setState((state) => {
const items = Object.values(state.items).reduce<string[]>((acc, item) => {
if (!item.hasSearched) {
acc.push(item.id);
}
return acc;
}, []);
return { isProcessing: true, lookupQueue: items };
});
};
export const stopProcessing = () => {
importSeriesStore.setState({ isProcessing: false, lookupQueue: [] });
};
export const addToLookupQueue = (id: string) => {
importSeriesStore.setState((state) => ({
lookupQueue: [...state.lookupQueue, id],
}));
};
export const removeFromLookupQueue = (id: string) => {
importSeriesStore.setState((state) => ({
lookupQueue: state.lookupQueue.filter((queuedId) => queuedId !== id),
}));
};
export const useIsCurrentLookupQueueItem = (id: string) => {
return importSeriesStore((state) => state.lookupQueue[0] === id);
};
export const useIsCurrentedItemQueued = (id: string) => {
return importSeriesStore((state) => state.lookupQueue.includes(id));
};
export const useLookupQueueHasItems = () => {
return importSeriesStore((state) => state.lookupQueue.length > 0);
};
export const useImportSeriesItem = (id: string) => {
return importSeriesStore((state) => state.items[id]);
};
export const useImportSeriesItems = () => {
return importSeriesStore(useShallow((state) => Object.values(state.items)));
};
export const getImportSeriesItems = (ids: string[]) => {
const state = importSeriesStore.getState();
return ids.reduce<ImportSeriesItem[]>((acc, id) => {
const item = state.items[id];
if (item != null) {
acc.push(item);
}
return acc;
}, []);
};

View File

@@ -1,85 +0,0 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import Series from 'Series/Series';
import {
getImportSeriesItems,
removeImportSeriesItemByPath,
} from './importSeriesStore';
export const useImportSeries = () => {
const queryClient = useQueryClient();
const { isPending, error, mutate } = useApiMutation<Series[], Series[]>({
path: '/series/import',
method: 'POST',
mutationOptions: {
onSuccess: (data, newSeries) => {
queryClient.invalidateQueries({ queryKey: ['/rootFolder'] });
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return data;
}
return [...oldSeries, ...data];
});
newSeries.forEach((series) => {
removeImportSeriesItemByPath(series.path);
});
},
},
});
const importSeries = useCallback(
(ids: string[]) => {
const items = getImportSeriesItems(ids);
const addedIds: string[] = [];
const allNewSeries = ids.reduce<Series[]>((acc, id) => {
const item = items.find((i) => i.id === id);
const selectedSeries = item?.selectedSeries;
// Make sure we have a selected series and the same series hasn't been added yet.
if (
selectedSeries &&
!acc.some((a) => a.tvdbId === selectedSeries.tvdbId)
) {
const newSeries: Series = {
...selectedSeries,
monitored: true,
monitorNewItems: 'all',
qualityProfileId: item.qualityProfileId,
path: item.path,
seriesType: item.seriesType,
seasonFolder: item.seasonFolder,
addOptions: {
monitor: item.monitor,
searchForMissingEpisodes: false,
searchForCutoffUnmetEpisodes: false,
},
tags: [],
};
newSeries.path = item.path;
addedIds.push(id);
acc.push(newSeries);
}
return acc;
}, []);
if (allNewSeries.length > 0) {
mutate(allNewSeries);
}
},
[mutate]
);
return {
isImporting: isPending,
importError: error,
importSeries,
};
};

View File

@@ -1,4 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet'; import FieldSet from 'Components/FieldSet';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
@@ -11,24 +13,28 @@ import PageContentBody from 'Components/Page/PageContentBody';
import usePrevious from 'Helpers/Hooks/usePrevious'; import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, kinds, sizes } from 'Helpers/Props'; import { icons, kinds, sizes } from 'Helpers/Props';
import RootFolders from 'RootFolder/RootFolders'; import RootFolders from 'RootFolder/RootFolders';
import useRootFolders, { useAddRootFolder } from 'RootFolder/useRootFolders'; import {
import { useIsWindows } from 'System/Status/useSystemStatus'; addRootFolder,
fetchRootFolders,
} from 'Store/Actions/rootFolderActions';
import useIsWindows from 'System/useIsWindows';
import { InputChanged } from 'typings/inputs'; import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './ImportSeriesSelectFolder.css'; import styles from './ImportSeriesSelectFolder.css';
function ImportSeriesSelectFolder() { function ImportSeriesSelectFolder() {
const { isFetching, isFetched, error, data } = useRootFolders(); const dispatch = useDispatch();
const { addRootFolder, isAdding, addError } = useAddRootFolder(); const { isFetching, isPopulated, isSaving, error, saveError, items } =
useSelector((state: AppState) => state.rootFolders);
const isWindows = useIsWindows(); const isWindows = useIsWindows();
const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] = const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
useState(false); useState(false);
const wasAdding = usePrevious(isAdding); const wasSaving = usePrevious(isSaving);
const hasRootFolders = data.length > 0; const hasRootFolders = items.length > 0;
const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows'; const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows';
const badFolderExample = isWindows const badFolderExample = isWindows
? 'C:\\tv shows\\the simpsons' ? 'C:\\tv shows\\the simpsons'
@@ -44,14 +50,18 @@ function ImportSeriesSelectFolder() {
const handleNewRootFolderSelect = useCallback( const handleNewRootFolderSelect = useCallback(
({ value }: InputChanged<string>) => { ({ value }: InputChanged<string>) => {
addRootFolder({ path: value }); dispatch(addRootFolder({ path: value }));
}, },
[addRootFolder] [dispatch]
); );
useEffect(() => { useEffect(() => {
if (!isAdding && wasAdding && !addError) { dispatch(fetchRootFolders());
data.reduce((acc, item) => { }, [dispatch]);
useEffect(() => {
if (!isSaving && wasSaving && !saveError) {
items.reduce((acc, item) => {
if (item.id > acc) { if (item.id > acc) {
return item.id; return item.id;
} }
@@ -59,18 +69,18 @@ function ImportSeriesSelectFolder() {
return acc; return acc;
}, 0); }, 0);
} }
}, [isAdding, wasAdding, addError, data]); }, [isSaving, wasSaving, saveError, items]);
return ( return (
<PageContent title={translate('ImportSeries')}> <PageContent title={translate('ImportSeries')}>
<PageContentBody> <PageContentBody>
{isFetching && !isFetched ? <LoadingIndicator /> : null} {isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && error ? ( {!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert> <Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert>
) : null} ) : null}
{!error && isFetched && ( {!error && isPopulated && (
<div> <div>
<div className={styles.header}> <div className={styles.header}>
{translate('LibraryImportSeriesHeader')} {translate('LibraryImportSeriesHeader')}
@@ -108,17 +118,17 @@ function ImportSeriesSelectFolder() {
</div> </div>
) : null} ) : null}
{!isAdding && addError ? ( {!isSaving && saveError ? (
<Alert className={styles.addErrorAlert} kind={kinds.DANGER}> <Alert className={styles.addErrorAlert} kind={kinds.DANGER}>
{translate('AddRootFolderError')} {translate('AddRootFolderError')}
<ul> <ul>
{Array.isArray(addError.statusBody) ? ( {Array.isArray(saveError.responseJSON) ? (
addError.statusBody.map((e, index) => { saveError.responseJSON.map((e, index) => {
return <li key={index}>{e.errorMessage}</li>; return <li key={index}>{e.errorMessage}</li>;
}) })
) : ( ) : (
<li>{JSON.stringify(addError.statusBody)}</li> <li>{JSON.stringify(saveError.responseJSON)}</li>
)} )}
</ul> </ul>
</Alert> </Alert>

View File

@@ -1,4 +1,4 @@
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore'; import { createPersist } from 'Helpers/createPersist';
import { SeriesMonitor, SeriesType } from 'Series/Series'; import { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeriesOptions { export interface AddSeriesOptions {
@@ -12,8 +12,9 @@ export interface AddSeriesOptions {
tags: number[]; tags: number[];
} }
const { useOptions, useOption, setOption } = const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
createOptionsStore<AddSeriesOptions>('add_series_options', () => { 'add_series_options',
() => {
return { return {
rootFolderPath: '', rootFolderPath: '',
monitor: 'all', monitor: 'all',
@@ -24,8 +25,25 @@ const { useOptions, useOption, setOption } =
searchForCutoffUnmetEpisodes: false, searchForCutoffUnmetEpisodes: false,
tags: [], tags: [],
}; };
}); }
);
export const useAddSeriesOptions = useOptions; export const useAddSeriesOptions = () => {
export const useAddSeriesOption = useOption; return addSeriesOptionsStore((state) => state);
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,
}));
};

View File

@@ -1,4 +1,4 @@
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router'; import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
import React from 'react'; import React from 'react';
import DocumentTitle from 'react-document-title'; import DocumentTitle from 'react-document-title';
@@ -7,13 +7,14 @@ import { Store } from 'redux';
import Page from 'Components/Page/Page'; import Page from 'Components/Page/Page';
import ApplyTheme from './ApplyTheme'; import ApplyTheme from './ApplyTheme';
import AppRoutes from './AppRoutes'; import AppRoutes from './AppRoutes';
import { queryClient } from './queryClient';
interface AppProps { interface AppProps {
store: Store; store: Store;
history: ConnectedRouterProps['history']; history: ConnectedRouterProps['history'];
} }
const queryClient = new QueryClient();
function App({ store, history }: AppProps) { function App({ store, history }: AppProps) {
return ( return (
<DocumentTitle title={window.Sonarr.instanceName}> <DocumentTitle title={window.Sonarr.instanceName}>

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useDispatch, 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';
@@ -8,11 +9,12 @@ 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';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import { useAppValues } from './appStore'; import AppState from './State/AppState';
import styles from './AppUpdatedModalContent.css'; import styles from './AppUpdatedModalContent.css';
function mergeUpdates(items: Update[], version: string, prevVersion?: string) { function mergeUpdates(items: Update[], version: string, prevVersion?: string) {
@@ -62,8 +64,9 @@ interface AppUpdatedModalContentProps {
} }
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) { function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const { version, prevVersion } = useAppValues('version', 'prevVersion'); const dispatch = useDispatch();
const { isFetched, error, data, refetch } = useUpdates(); const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { isFetched, error, data } = useUpdates();
const previousVersion = usePrevious(version); const previousVersion = usePrevious(version);
const { onModalClose } = props; const { onModalClose } = props;
@@ -74,11 +77,15 @@ 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) {
refetch(); dispatch(fetchUpdates());
} }
}, [version, previousVersion, refetch]); }, [version, previousVersion, dispatch]);
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>

View File

@@ -1,35 +0,0 @@
import React, { createContext, PropsWithChildren } from 'react';
import useSelectStore, {
Id,
SelectStoreModel,
} from 'App/Select/useSelectStore';
interface SelectProviderProps<T extends SelectStoreModel<Id>>
extends PropsWithChildren {
items: Array<T>;
}
const SelectContext = createContext<
ReturnType<typeof useSelectStore> | undefined
>(undefined);
export function SelectProvider<T extends SelectStoreModel<Id>>({
items,
children,
}: SelectProviderProps<T>) {
const value = useSelectStore<T>(items);
return (
<SelectContext.Provider value={value}>{children}</SelectContext.Provider>
);
}
export function useSelect<T extends SelectStoreModel<Id>>() {
const context = React.useContext(SelectContext);
if (context === undefined) {
throw new Error('useSelect must be used within a SelectProvider');
}
return context as ReturnType<typeof useSelectStore<T>>;
}

View File

@@ -1,314 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { create, useStore } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import getToggledRange from 'Utilities/Table/getToggledRange';
export type Id = string | number;
export type SelectStoreReturnType<T extends SelectStoreModel<Id>> = ReturnType<
typeof useSelectStore<T>
>;
type ItemState<T extends SelectStoreModel<Id>> = Map<T['id'], ItemStateValue>;
interface ItemStateValue {
isSelected: boolean;
isDisabled?: boolean;
}
export interface SelectStoreModel<TId extends Id> {
id: TId;
}
export interface SelectStore<T extends SelectStoreModel<Id>> {
itemState: Map<T['id'], ItemStateValue>;
lastToggled: T['id'] | null;
items: T[];
}
interface ItemSelectState {
allSelected: boolean;
allUnselected: boolean;
anySelected: boolean;
selectedCount: number;
}
const initialState = <T extends SelectStoreModel<Id>>(
items: T[] = []
): SelectStore<T> => ({
itemState: new Map<T['id'], ItemStateValue>(),
lastToggled: null,
items,
});
function toggleAll<T extends SelectStoreModel<Id>>(
itemState: ItemState<T>,
isSelected: boolean
) {
const newItemState = new Map(itemState);
newItemState.forEach((value, key) => {
newItemState.set(key, {
isSelected: value.isDisabled ? value.isSelected : isSelected,
isDisabled: value.isDisabled,
});
});
return newItemState;
}
export default function useSelectStore<T extends SelectStoreModel<Id>>(
items: SelectStoreModel<T['id']>[]
) {
const store = useRef(
create<SelectStore<T>>(() => initialState(items as T[]))
);
const [itemSelectState, setItemSelectState] = useState<ItemSelectState>({
allSelected: false,
allUnselected: true,
anySelected: false,
selectedCount: 0,
});
const reset = useCallback(() => {
store.current.setState(initialState(items as T[]), true);
}, [items]);
const selectAll = useCallback(() => {
store.current.setState((state) => {
const newItemState = toggleAll(state.itemState, true);
return {
lastToggled: null,
itemState: newItemState,
};
});
}, []);
const unselectAll = useCallback(() => {
store.current.setState((state) => {
const newItemState = toggleAll(state.itemState, false);
return {
lastToggled: null,
itemState: newItemState,
};
});
}, []);
const toggleSelected = useCallback(
({
id,
isSelected,
shiftKey,
}: {
id: T['id'];
isSelected: boolean | null;
shiftKey: boolean;
}) => {
store.current.setState((state) => {
const lastToggled = state.lastToggled;
const nextSelectedState = new Map(state.itemState);
const currentItemState = nextSelectedState.get(id);
if (isSelected == null) {
nextSelectedState.delete(id);
} else if (!currentItemState?.isDisabled) {
nextSelectedState.set(id, {
isSelected,
isDisabled: currentItemState?.isDisabled,
});
if (shiftKey && lastToggled) {
const { lower, upper } = getToggledRange(
state.items,
id,
lastToggled
);
for (let i = lower; i < upper; i++) {
if (!nextSelectedState.get(state.items[i].id)?.isDisabled) {
nextSelectedState.set(state.items[i].id, {
isSelected,
isDisabled: currentItemState?.isDisabled,
});
}
}
}
}
return {
...state,
lastToggled: id,
itemState: nextSelectedState,
};
});
},
[]
);
const toggleDisabled = useCallback((id: T['id'], isDisabled: boolean) => {
store.current.setState((state) => {
const currentItemState = state.itemState.get(id);
if (currentItemState) {
const newItemState = new Map(state.itemState);
newItemState.set(id, {
...currentItemState,
isDisabled,
});
return {
itemState: newItemState,
};
}
return state;
});
}, []);
const getSelectedIds = useCallback((): Array<T['id']> => {
const iState = store.current.getState().itemState;
return Array.from(iState.entries()).reduce<T['id'][]>(
(acc, [id, value]) => {
if (value.isSelected) {
acc.push(id);
}
return acc;
},
[]
);
}, []);
const getIsSelected = useCallback((id: T['id']): boolean => {
const item = store.current.getState().itemState.get(id);
return item?.isSelected ?? false;
}, []);
const useIsSelected = (id: T['id']) => {
return useStore(
store.current,
useShallow((state) => {
const item = state.itemState.get(id);
return item?.isSelected ?? false;
})
);
};
const useSelectedIds = () => {
return useStore(
store.current,
useShallow((state) => {
return state.itemState
.entries()
.reduce<T['id'][]>((acc, [id, value]) => {
if (value.isSelected) {
acc.push(id);
}
return acc;
}, []);
})
);
};
const useHasItems = () => {
return useStore(
store.current,
useShallow((state) => {
return state.itemState.size > 0;
})
);
};
useEffect(() => {
const unsubscribe = store.current.subscribe((state) => {
const itemState = state.itemState;
const { allSelected, allUnselected, anySelected, selectedCount } =
itemState.values().reduce(
(acc, item) => {
acc.allSelected =
acc.allSelected && !!(item.isSelected || item.isDisabled);
acc.allUnselected =
acc.allUnselected && (!item.isSelected || !!item.isDisabled);
acc.anySelected = acc.anySelected || item.isSelected;
acc.selectedCount += item.isSelected ? 1 : 0;
return acc;
},
{
allSelected:
itemState.size > 0 &&
itemState.values().some((i) => i.isSelected),
allUnselected: true,
anySelected: false,
selectedCount: 0,
}
);
setItemSelectState((s) => {
if (
s.allSelected === allSelected &&
s.allUnselected === allUnselected &&
s.anySelected === anySelected &&
s.selectedCount === selectedCount
) {
return s;
}
return {
allSelected,
allUnselected,
anySelected,
selectedCount,
};
});
});
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
store.current.setState((state) => {
const nextItemState = items.reduce((acc: ItemState<T>, item) => {
const id = item.id;
const existingItem = state.itemState.get(id);
acc.set(
id,
existingItem ?? {
isSelected: false,
isDisabled: false,
}
);
return acc;
}, new Map<T['id'], ItemStateValue>());
return {
itemState: nextItemState,
lastToggled: null,
items: items as T[],
};
});
}, [items]);
return {
...itemSelectState,
getIsSelected,
getSelectedIds,
reset,
selectAll,
toggleDisabled,
toggleSelected,
unselectAll,
useHasItems,
useIsSelected,
useSelectedIds,
};
}

View File

@@ -0,0 +1,86 @@
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect } from 'react';
import useSelectState, {
SelectState,
SelectStateModel,
} from 'Helpers/Hooks/useSelectState';
import ModelBase from './ModelBase';
export type SelectContextAction =
| { type: 'reset' }
| { type: 'selectAll' }
| { type: 'unselectAll' }
| {
type: 'toggleSelected';
id: number | string;
isSelected: boolean | null;
shiftKey: boolean;
}
| {
type: 'removeItem';
id: number | string;
}
| {
type: 'updateItems';
items: ModelBase[];
};
export type SelectDispatch = (action: SelectContextAction) => void;
interface SelectProviderOptions<T extends SelectStateModel> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: any;
items: Array<T>;
}
const SelectContext = React.createContext<
[SelectState, SelectDispatch] | undefined
>(cloneDeep(undefined));
export function SelectProvider<T extends SelectStateModel>(
props: SelectProviderOptions<T>
) {
const { items } = props;
const [state, dispatch] = useSelectState();
const dispatchWrapper = useCallback(
(action: SelectContextAction) => {
switch (action.type) {
case 'reset':
case 'removeItem':
dispatch(action);
break;
default:
dispatch({
...action,
items,
});
break;
}
},
[items, dispatch]
);
const value: [SelectState, SelectDispatch] = [state, dispatchWrapper];
useEffect(() => {
dispatch({ type: 'updateItems', items });
}, [items, dispatch]);
return (
<SelectContext.Provider value={value}>
{props.children}
</SelectContext.Provider>
);
}
export function useSelect() {
const context = React.useContext(SelectContext);
if (context === undefined) {
throw new Error('useSelect must be used within a SelectProvider');
}
return context;
}

View File

@@ -1,7 +1,7 @@
import Column from 'Components/Table/Column'; import Column from 'Components/Table/Column';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { SortDirection } from 'Helpers/Props/sortDirections'; import { SortDirection } from 'Helpers/Props/sortDirections';
import { ValidationFailure } from 'typings/pending'; import { ValidationFailure } from 'typings/pending';
import { Filter, FilterBuilderProp } from './AppState';
export interface Error { export interface Error {
status?: number; status?: number;

View File

@@ -1,9 +1,114 @@
import ModelBase from 'App/ModelBase';
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
import { Error } from './AppSectionState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CaptchaAppState from './CaptchaAppState'; import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState';
import CustomFiltersAppState from './CustomFiltersAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState';
import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState';
import ImportSeriesAppState from './ImportSeriesAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import MessagesAppState from './MessagesAppState';
import OAuthAppState from './OAuthAppState';
import OrganizePreviewAppState from './OrganizePreviewAppState';
import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState'; import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState';
import WantedAppState from './WantedAppState';
export interface FilterBuilderPropOption {
id: string;
name: string;
}
export interface FilterBuilderProp<T> {
name: string;
label: string | (() => string);
type: FilterBuilderTypes;
valueType?: string;
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
}
export interface PropertyFilter {
key: string;
value: string | string[] | number[] | boolean[] | DateFilterValue;
type: FilterType;
}
export interface Filter {
key: string;
label: string | (() => string);
filters: PropertyFilter[];
}
export interface CustomFilter extends ModelBase {
type: string;
label: string;
filters: PropertyFilter[];
}
export interface AppSectionState {
isUpdated: boolean;
isConnected: boolean;
isDisconnected: boolean;
isReconnecting: boolean;
isRestarting: boolean;
isSidebarVisible: boolean;
version: string;
prevVersion?: string;
dimensions: {
isSmallScreen: boolean;
isLargeScreen: boolean;
width: number;
height: number;
};
translations: {
error?: Error;
isPopulated: boolean;
};
messages: MessagesAppState;
}
interface AppState { interface AppState {
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;
captcha: CaptchaAppState; captcha: CaptchaAppState;
commands: CommandAppState;
customFilters: CustomFiltersAppState;
episodeFiles: EpisodeFilesAppState;
episodeHistory: HistoryAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;
importSeries: ImportSeriesAppState;
interactiveImport: InteractiveImportAppState;
oAuth: OAuthAppState;
organizePreview: OrganizePreviewAppState;
parse: ParseAppState;
paths: PathsAppState;
providerOptions: ProviderOptionsAppState;
queue: QueueAppState;
releases: ReleasesAppState;
rootFolders: RootFolderAppState;
series: SeriesAppState;
seriesHistory: SeriesHistoryAppState;
seriesIndex: SeriesIndexAppState;
settings: SettingsAppState; settings: SettingsAppState;
system: SystemAppState;
tags: TagsAppState;
wanted: WantedAppState;
} }
export default AppState; export default AppState;

View File

@@ -0,0 +1,9 @@
import Backup from 'typings/Backup';
import AppSectionState, { Error } from './AppSectionState';
interface BackupAppState extends AppSectionState<Backup> {
isRestoring: boolean;
restoreError?: Error;
}
export default BackupAppState;

View File

@@ -0,0 +1,16 @@
import Blocklist from 'typings/Blocklist';
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from './AppSectionState';
interface BlocklistAppState
extends AppSectionState<Blocklist>,
AppSectionFilterState<Blocklist>,
PagedAppSectionState,
TableAppSectionState {
isRemoving: boolean;
}
export default BlocklistAppState;

View File

@@ -0,0 +1,29 @@
import moment from 'moment';
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import { CalendarView } from 'Calendar/calendarViews';
import { CalendarItem } from 'typings/Calendar';
interface CalendarOptions {
showEpisodeInformation: boolean;
showFinaleIcon: boolean;
showSpecialIcon: boolean;
showCutoffUnmetIcon: boolean;
collapseMultipleEpisodes: boolean;
fullColorEvents: boolean;
}
interface CalendarAppState
extends AppSectionState<CalendarItem>,
AppSectionFilterState<CalendarItem> {
searchMissingCommandId: number | null;
start: moment.Moment;
end: moment.Moment;
dates: string[];
time: string;
view: CalendarView;
options: CalendarOptions;
}
export default CalendarAppState;

View File

@@ -1,5 +1,8 @@
import { CustomFilter } from './AppState';
interface ClientSideCollectionAppState { interface ClientSideCollectionAppState {
totalItems: number; totalItems: number;
customFilters: CustomFilter[];
} }
export default ClientSideCollectionAppState; export default ClientSideCollectionAppState;

View File

@@ -0,0 +1,6 @@
import AppSectionState from 'App/State/AppSectionState';
import Command from 'Commands/Command';
export type CommandAppState = AppSectionState<Command>;
export default CommandAppState;

View File

@@ -0,0 +1,12 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import { CustomFilter } from './AppState';
interface CustomFiltersAppState
extends AppSectionState<CustomFilter>,
AppSectionDeleteState,
AppSectionSaveState {}
export default CustomFiltersAppState;

View File

@@ -0,0 +1,10 @@
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
interface EpisodeFilesAppState
extends AppSectionState<EpisodeFile>,
AppSectionDeleteState {}
export default EpisodeFilesAppState;

View File

@@ -0,0 +1,9 @@
import AppSectionState from 'App/State/AppSectionState';
import Column from 'Components/Table/Column';
import Episode from 'Episode/Episode';
interface EpisodesAppState extends AppSectionState<Episode> {
columns: Column[];
}
export default EpisodesAppState;

View File

@@ -0,0 +1,16 @@
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from 'App/State/AppSectionState';
import History from 'typings/History';
export type SeriesHistoryAppState = AppSectionState<History>;
interface HistoryAppState
extends AppSectionState<History>,
AppSectionFilterState<History>,
PagedAppSectionState,
TableAppSectionState {}
export default HistoryAppState;

View File

@@ -0,0 +1,29 @@
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
import { Error } from './AppSectionState';
export interface ImportSeries {
id: string;
error?: Error;
isFetching: boolean;
isPopulated: boolean;
isQueued: boolean;
items: Series[];
monitor: SeriesMonitor;
path: string;
qualityProfileId: number;
relativePath: string;
seasonFolder: boolean;
selectedSeries?: Series;
seriesType: SeriesType;
term: string;
}
interface ImportSeriesAppState {
isLookingUpSeries: false;
isImporting: false;
isImported: false;
importError: Error | null;
items: ImportSeries[];
}
export default ImportSeriesAppState;

View File

@@ -0,0 +1,21 @@
import AppSectionState from 'App/State/AppSectionState';
import ImportMode from 'InteractiveImport/ImportMode';
import InteractiveImport from 'InteractiveImport/InteractiveImport';
interface FavoriteFolder {
folder: string;
}
interface RecentFolder {
folder: string;
lastUsed: string;
}
interface InteractiveImportAppState extends AppSectionState<InteractiveImport> {
originalItems: InteractiveImport[];
importMode: ImportMode;
favoriteFolders: FavoriteFolder[];
recentFolders: RecentFolder[];
}
export default InteractiveImportAppState;

View File

@@ -0,0 +1,15 @@
import ModelBase from 'App/ModelBase';
import AppSectionState from 'App/State/AppSectionState';
export type MessageType = 'error' | 'info' | 'success' | 'warning';
export interface Message extends ModelBase {
hideAfter: number;
message: string;
name: string;
type: MessageType;
}
type MessagesAppState = AppSectionState<Message>;
export default MessagesAppState;

View File

@@ -0,0 +1,6 @@
import { AppSectionProviderState } from 'App/State/AppSectionState';
import Metadata from 'typings/Metadata';
type MetadataAppState = AppSectionProviderState<Metadata>;
export default MetadataAppState;

View File

@@ -0,0 +1,9 @@
import { Error } from './AppSectionState';
interface OAuthAppState {
authorizing: boolean;
result: Record<string, unknown> | null;
error: Error;
}
export default OAuthAppState;

View File

@@ -0,0 +1,15 @@
import ModelBase from 'App/ModelBase';
import AppSectionState from 'App/State/AppSectionState';
export interface OrganizePreviewModel extends ModelBase {
seriesId: number;
seasonNumber: number;
episodeNumbers: number[];
episodeFileId: number;
existingPath: string;
newPath: string;
}
type OrganizePreviewAppState = AppSectionState<OrganizePreviewModel>;
export default OrganizePreviewAppState;

View File

@@ -1,4 +1,5 @@
import ModelBase from 'App/ModelBase'; import ModelBase from 'App/ModelBase';
import { AppSectionItemState } from 'App/State/AppSectionState';
import Episode from 'Episode/Episode'; import Episode from 'Episode/Episode';
import Language from 'Language/Language'; import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality'; import { QualityModel } from 'Quality/Quality';
@@ -47,3 +48,7 @@ export interface ParseModel extends ModelBase {
customFormats?: CustomFormat[]; customFormats?: CustomFormat[];
customFormatScore?: number; customFormatScore?: number;
} }
type ParseAppState = AppSectionItemState<ParseModel>;
export default ParseAppState;

View File

@@ -0,0 +1,29 @@
interface BasePath {
name: string;
path: string;
size: number;
lastModified: string;
}
interface File extends BasePath {
type: 'file';
}
interface Folder extends BasePath {
type: 'folder';
}
export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent';
export type Path = File | Folder;
interface PathsAppState {
currentPath: string;
isFetching: boolean;
isPopulated: boolean;
error: Error;
directories: Folder[];
files: File[];
parent: string | null;
}
export default PathsAppState;

View File

@@ -0,0 +1,22 @@
import AppSectionState from 'App/State/AppSectionState';
import Field, { FieldSelectOption } from 'typings/Field';
export interface ProviderOptions {
fields?: Field[];
}
interface ProviderOptionsDevice {
id: string;
name: string;
}
interface ProviderOptionsAppState {
devices: AppSectionState<ProviderOptionsDevice>;
servers: AppSectionState<FieldSelectOption<unknown>>;
newznabCategories: AppSectionState<FieldSelectOption<unknown>>;
getProfiles: AppSectionState<FieldSelectOption<unknown>>;
getTags: AppSectionState<FieldSelectOption<unknown>>;
getRootFolders: AppSectionState<FieldSelectOption<unknown>>;
}
export default ProviderOptionsAppState;

View File

@@ -0,0 +1,56 @@
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;

View File

@@ -0,0 +1,10 @@
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import Release from 'typings/Release';
interface ReleasesAppState
extends AppSectionState<Release>,
AppSectionFilterState<Release> {}
export default ReleasesAppState;

View File

@@ -0,0 +1,12 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import RootFolder from 'typings/RootFolder';
interface RootFolderAppState
extends AppSectionState<RootFolder>,
AppSectionDeleteState,
AppSectionSaveState {}
export default RootFolderAppState;

View File

@@ -0,0 +1,66 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Column from 'Components/Table/Column';
import { SortDirection } from 'Helpers/Props/sortDirections';
import Series from 'Series/Series';
import { Filter, FilterBuilderProp } from './AppState';
export interface SeriesIndexAppState {
sortKey: string;
sortDirection: SortDirection;
secondarySortKey: string;
secondarySortDirection: SortDirection;
view: string;
posterOptions: {
detailedProgressBar: boolean;
size: string;
showTitle: boolean;
showMonitored: boolean;
showQualityProfile: boolean;
showTags: boolean;
showSearchAction: boolean;
};
overviewOptions: {
detailedProgressBar: boolean;
size: string;
showMonitored: boolean;
showNetwork: boolean;
showQualityProfile: boolean;
showPreviousAiring: boolean;
showAdded: boolean;
showSeasonCount: boolean;
showPath: boolean;
showSizeOnDisk: boolean;
showTags: boolean;
showSearchAction: boolean;
};
tableOptions: {
showBanners: boolean;
showSearchAction: boolean;
};
selectedFilterKey: string;
filterBuilderProps: FilterBuilderProp<Series>[];
filters: Filter[];
columns: Column[];
}
interface SeriesAppState
extends AppSectionState<Series>,
AppSectionDeleteState,
AppSectionSaveState {
itemMap: Record<number, number>;
deleteOptions: {
addImportListExclusion: boolean;
};
pendingChanges: Partial<Series>;
}
export default SeriesAppState;

View File

@@ -1,11 +1,13 @@
import AppSectionState, { import AppSectionState, {
AppSectionDeleteState, AppSectionDeleteState,
AppSectionItemSchemaState,
AppSectionItemState, AppSectionItemState,
AppSectionListState, AppSectionListState,
AppSectionSaveState, AppSectionSaveState,
AppSectionSchemaState, AppSectionSchemaState,
PagedAppSectionState, PagedAppSectionState,
} from 'App/State/AppSectionState'; } from 'App/State/AppSectionState';
import Language from 'Language/Language';
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging'; import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
import CustomFormat from 'typings/CustomFormat'; import CustomFormat from 'typings/CustomFormat';
import CustomFormatSpecification from 'typings/CustomFormatSpecification'; import CustomFormatSpecification from 'typings/CustomFormatSpecification';
@@ -14,7 +16,21 @@ import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList'; import ImportList from 'typings/ImportList';
import ImportListExclusion from 'typings/ImportListExclusion'; import ImportListExclusion from 'typings/ImportListExclusion';
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
import Indexer from 'typings/Indexer';
import IndexerFlag from 'typings/IndexerFlag';
import Notification from 'typings/Notification';
import QualityDefinition from 'typings/QualityDefinition';
import QualityProfile from 'typings/QualityProfile';
import DownloadClientOptions from 'typings/Settings/DownloadClientOptions'; import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
import General from 'typings/Settings/General';
import IndexerOptions from 'typings/Settings/IndexerOptions';
import MediaManagement from 'typings/Settings/MediaManagement';
import NamingConfig from 'typings/Settings/NamingConfig';
import NamingExample from 'typings/Settings/NamingExample';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import RemotePathMapping from 'typings/Settings/RemotePathMapping';
import UiSettings from 'typings/Settings/UiSettings';
import MetadataAppState from './MetadataAppState';
type Presets<T> = T & { type Presets<T> = T & {
presets: T[]; presets: T[];
@@ -48,6 +64,20 @@ export interface DownloadClientOptionsAppState
extends AppSectionItemState<DownloadClientOptions>, extends AppSectionItemState<DownloadClientOptions>,
AppSectionSaveState {} AppSectionSaveState {}
export interface GeneralAppState
extends AppSectionItemState<General>,
AppSectionSaveState {}
export interface MediaManagementAppState
extends AppSectionItemState<MediaManagement>,
AppSectionSaveState {}
export interface NamingAppState
extends AppSectionItemState<NamingConfig>,
AppSectionSaveState {}
export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
export interface ImportListAppState export interface ImportListAppState
extends AppSectionState<ImportList>, extends AppSectionState<ImportList>,
AppSectionDeleteState, AppSectionDeleteState,
@@ -56,6 +86,44 @@ export interface ImportListAppState
isTestingAll: boolean; isTestingAll: boolean;
} }
export interface IndexerOptionsAppState
extends AppSectionItemState<IndexerOptions>,
AppSectionSaveState {}
export interface IndexerAppState
extends AppSectionState<Indexer>,
AppSectionDeleteState,
AppSectionSaveState,
AppSectionSchemaState<Presets<Indexer>> {
isTestingAll: boolean;
}
export interface NotificationAppState
extends AppSectionState<Notification>,
AppSectionDeleteState,
AppSectionSaveState,
AppSectionSchemaState<Presets<Notification>> {}
export interface QualityDefinitionsAppState
extends AppSectionState<QualityDefinition>,
AppSectionSaveState {
pendingChanges: {
[key: number]: Partial<QualityProfile>;
};
}
export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionItemSchemaState<QualityProfile>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface ReleaseProfilesAppState
extends AppSectionState<ReleaseProfile>,
AppSectionSaveState {
pendingChanges: Partial<ReleaseProfile>;
}
export interface CustomFormatAppState export interface CustomFormatAppState
extends AppSectionState<CustomFormat>, extends AppSectionState<CustomFormat>,
AppSectionDeleteState, AppSectionDeleteState,
@@ -79,7 +147,19 @@ export interface ImportListExclusionsSettingsAppState
pendingChanges: Partial<ImportListExclusion>; pendingChanges: Partial<ImportListExclusion>;
} }
export interface RemotePathMappingsAppState
extends AppSectionState<RemotePathMapping>,
AppSectionDeleteState,
AppSectionSaveState {
pendingChanges: Partial<RemotePathMapping>;
}
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
export type LanguageSettingsAppState = AppSectionState<Language>;
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState { interface SettingsAppState {
advancedSettings: boolean;
autoTaggings: AutoTaggingAppState; autoTaggings: AutoTaggingAppState;
autoTaggingSpecifications: AutoTaggingSpecificationAppState; autoTaggingSpecifications: AutoTaggingSpecificationAppState;
customFormats: CustomFormatAppState; customFormats: CustomFormatAppState;
@@ -87,9 +167,24 @@ interface SettingsAppState {
delayProfiles: DelayProfileAppState; delayProfiles: DelayProfileAppState;
downloadClients: DownloadClientAppState; downloadClients: DownloadClientAppState;
downloadClientOptions: DownloadClientOptionsAppState; downloadClientOptions: DownloadClientOptionsAppState;
general: GeneralAppState;
importListExclusions: ImportListExclusionsSettingsAppState; importListExclusions: ImportListExclusionsSettingsAppState;
importListOptions: ImportListOptionsSettingsAppState; importListOptions: ImportListOptionsSettingsAppState;
importLists: ImportListAppState; importLists: ImportListAppState;
indexerFlags: IndexerFlagSettingsAppState;
indexerOptions: IndexerOptionsAppState;
indexers: IndexerAppState;
languages: LanguageSettingsAppState;
mediaManagement: MediaManagementAppState;
metadata: MetadataAppState;
naming: NamingAppState;
namingExamples: NamingExamplesAppState;
notifications: NotificationAppState;
qualityDefinitions: QualityDefinitionsAppState;
qualityProfiles: QualityProfilesAppState;
releaseProfiles: ReleaseProfilesAppState;
remotePathMappings: RemotePathMappingsAppState;
ui: UiSettingsAppState;
} }
export default SettingsAppState; export default SettingsAppState;

View File

@@ -0,0 +1,25 @@
import DiskSpace from 'typings/DiskSpace';
import Health from 'typings/Health';
import LogFile from 'typings/LogFile';
import SystemStatus from 'typings/SystemStatus';
import Task from 'typings/Task';
import AppSectionState, { AppSectionItemState } from './AppSectionState';
import BackupAppState from './BackupAppState';
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type TaskAppState = AppSectionState<Task>;
export type LogFilesAppState = AppSectionState<LogFile>;
interface SystemAppState {
backups: BackupAppState;
diskSpace: DiskSpaceAppState;
health: HealthAppState;
logFiles: LogFilesAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
updateLogFiles: LogFilesAppState;
}
export default SystemAppState;

View File

@@ -0,0 +1,32 @@
import ModelBase from 'App/ModelBase';
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
export interface Tag extends ModelBase {
label: string;
}
export interface TagDetail extends ModelBase {
label: string;
autoTagIds: number[];
delayProfileIds: number[];
downloadClientIds: [];
importListIds: number[];
indexerIds: number[];
notificationIds: number[];
restrictionIds: number[];
seriesIds: number[];
}
export interface TagDetailAppState
extends AppSectionState<TagDetail>,
AppSectionDeleteState,
AppSectionSaveState {}
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {
details: TagDetailAppState;
}
export default TagsAppState;

View File

@@ -0,0 +1,29 @@
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
interface WantedEpisode extends Episode {
isSaving?: boolean;
}
interface WantedCutoffUnmetAppState
extends AppSectionState<WantedEpisode>,
AppSectionFilterState<WantedEpisode>,
PagedAppSectionState,
TableAppSectionState {}
interface WantedMissingAppState
extends AppSectionState<WantedEpisode>,
AppSectionFilterState<WantedEpisode>,
PagedAppSectionState,
TableAppSectionState {}
interface WantedAppState {
cutoffUnmet: WantedCutoffUnmetAppState;
missing: WantedMissingAppState;
}
export default WantedAppState;

View File

@@ -1,202 +0,0 @@
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import getQueryPath from 'Utilities/Fetch/getQueryPath';
import fetchJson from 'Utilities/requestAction';
function getDimensions(width: number, height: number) {
const dimensions = {
width,
height,
isExtraSmallScreen: width <= 480,
isSmallScreen: width <= 768,
isMediumScreen: width <= 992,
isLargeScreen: width <= 1200,
};
return dimensions;
}
interface Dimensions {
width: number;
height: number;
isExtraSmallScreen: boolean;
isSmallScreen: boolean;
isMediumScreen: boolean;
isLargeScreen: boolean;
}
interface AppState {
dimensions: Dimensions;
version: string;
prevVersion?: string;
isUpdated: boolean;
isConnected: boolean;
isReconnecting: boolean;
isDisconnected: boolean;
isRestarting: boolean;
isSidebarVisible: boolean;
}
// Variables for ping functionality
let abortPingServer: (() => void) | null = null;
let pingTimeout: ReturnType<typeof setTimeout> | null = null;
const useAppStore = create<AppState>()(() => {
const dimensions = getDimensions(window.innerWidth, window.innerHeight);
return {
dimensions,
version: window.Sonarr.version,
isUpdated: false,
isConnected: true,
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
isSidebarVisible: !dimensions.isSmallScreen,
};
});
export const useAppValues = <K extends keyof AppState>(...keys: K[]) => {
return useAppStore(
useShallow((state) => {
return keys.reduce((acc, key) => {
acc[key] = state[key];
return acc;
}, {} as Pick<AppState, K>);
})
);
};
export const useAppValue = <K extends keyof AppState>(key: K) => {
return useAppStore(useShallow((state) => state[key]));
};
export const useAppDimensions = () => {
return useAppStore(useShallow((state) => state.dimensions));
};
export const useAppDimension = <K extends keyof Dimensions>(key: K) => {
return useAppStore(useShallow((state) => state.dimensions[key]));
};
export const getAppDimensions = () => {
return useAppStore.getState().dimensions;
};
export const getAppValues = <K extends keyof AppState>(...keys: K[]) => {
const state = useAppStore.getState();
return keys.reduce((acc, key) => {
acc[key] = state[key];
return acc;
}, {} as Pick<AppState, K>);
};
export const getAppValue = <K extends keyof AppState>(key: K) => {
return useAppStore.getState()[key];
};
function pingServerAfterTimeout() {
if (abortPingServer) {
abortPingServer();
abortPingServer = null;
}
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
pingTimeout = setTimeout(async () => {
const { isRestarting, isConnected } = getAppValues(
'isRestarting',
'isConnected'
);
if (!isRestarting && isConnected) {
return;
}
const abortController = new AbortController();
abortPingServer = () => abortController.abort();
try {
await fetchJson({
url: getQueryPath('/system/status'),
method: 'GET',
signal: abortController.signal,
headers: {
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
});
abortPingServer = null;
pingTimeout = null;
setAppValue({
isRestarting: false,
});
} catch (error: unknown) {
abortPingServer = null;
pingTimeout = null;
if ((error as { status?: number }).status === 401) {
setAppValue({
isRestarting: false,
});
} else if (!abortController.signal.aborted) {
pingServerAfterTimeout();
}
}
}, 5000);
}
export const saveDimensions = ({
width,
height,
}: {
width: number;
height: number;
}) => {
const dimensions = getDimensions(width, height);
useAppStore.setState({ dimensions });
};
export const setVersion = ({ version }: { version: string }) => {
useAppStore.setState((state) => {
const newState: Partial<AppState> = {
version,
};
if (state.version !== version) {
if (!state.prevVersion) {
newState.prevVersion = state.version;
}
newState.isUpdated = true;
}
return newState;
});
};
export const setIsSidebarVisible = ({
isSidebarVisible,
}: {
isSidebarVisible: boolean;
}) => {
useAppStore.setState({ isSidebarVisible });
};
export const toggleIsSidebarVisible = () => {
useAppStore.setState((state) => ({
isSidebarVisible: !state.isSidebarVisible,
}));
};
export const setAppValue = (payload: Partial<AppState>) => {
useAppStore.setState(payload);
};
export const pingServer = () => {
pingServerAfterTimeout();
};

View File

@@ -1,56 +0,0 @@
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import ModelBase from './ModelBase';
export type MessageType = 'error' | 'info' | 'success' | 'warning';
export interface Message extends ModelBase {
hideAfter: number;
message: string;
name: string;
type: MessageType;
}
interface MessagesState {
messages: Message[];
}
const useMessagesStore = create<MessagesState>()(() => ({
messages: [],
}));
export const useMessages = () => {
return useMessagesStore(useShallow((state) => state.messages));
};
export const getMessages = () => {
return useMessagesStore.getState().messages;
};
export const showMessage = (payload: Message) => {
useMessagesStore.setState((state) => {
const messages = [...state.messages];
const index = messages.findIndex((item) => item.id === payload.id);
if (index >= 0) {
const item = messages[index];
messages.splice(index, 1, { ...item, ...payload });
} else {
messages.push({ ...payload });
}
return {
messages,
};
});
};
export const hideMessage = ({ id }: { id: string | number }) => {
useMessagesStore.setState((state) => {
const messages = state.messages.filter((item) => item.id !== id);
return {
messages,
};
});
};

View File

@@ -1,3 +0,0 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient();

View File

@@ -1,28 +0,0 @@
import { useEffect } from 'react';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import { setTranslations } from 'Utilities/String/translate';
interface TranslationsResponse {
strings: Record<string, string>;
}
export function useTranslations() {
const { data, ...result } = useApiQuery<TranslationsResponse>({
path: '/localization',
queryOptions: {
staleTime: Infinity,
gcTime: Infinity,
},
});
useEffect(() => {
if (data) {
setTranslations(data.strings);
}
}, [data]);
return {
...result,
data,
};
}

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