mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-18 21:35:27 -04:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31a5a32356 | |||
| 550cf8d399 | |||
| 37dfad11f2 | |||
| ff5e73273b | |||
| fce8e780cb | |||
| 4fb89252b5 | |||
| 5a3f41263a | |||
| 52d7f67627 | |||
| a1db23353c | |||
| 79b56c3ff6 | |||
| 5568746ef8 | |||
| 813d7df643 | |||
| b04b9f900f | |||
| a45b077625 | |||
| 74ce132556 | |||
| ca364724cf | |||
| a4f210855e | |||
| bc4ad574fc | |||
| a5ea19ddfb | |||
| 858c690543 | |||
| 8e169561f2 | |||
| 994faa60c6 | |||
| a4a18d6121 | |||
| 0407564784 | |||
| 0a61e66ef1 | |||
| 051451eb2a | |||
| a4e3be721d | |||
| 224e74605b | |||
| 6c581b7e3c | |||
| 6588ba8435 | |||
| ce4c2e4fcc | |||
| b1f77007dc | |||
| 8bab0a06dd | |||
| 5b135addaa | |||
| 4904e85887 | |||
| c4978022eb | |||
| 15e9350601 | |||
| 2e1289b924 | |||
| 7dac00d5aa | |||
| 3796c9e30f | |||
| f2f4edad0c | |||
| b0b15c78ff | |||
| 64c421c187 | |||
| 6440151053 | |||
| cf6b21aef6 | |||
| 1610e54650 | |||
| c40fbeed50 | |||
| b57e7e2db0 | |||
| 478866b2bb | |||
| ae201f5299 | |||
| 642f4f97bc | |||
| 37cb978f18 | |||
| 7fdc4d6638 | |||
| 309b55fe38 | |||
| d6f265c7b5 | |||
| e757dca038 | |||
| 9ebe043bd9 | |||
| f055e8a3e5 | |||
| 8c697afa67 | |||
| 8d68879edd | |||
| e9c82078da | |||
| f0798550af | |||
| d9c7838329 | |||
| b00229e53c | |||
| 880628fb68 | |||
| b09c6f0811 | |||
| b376b63c9e | |||
| 99feaa34d2 | |||
| d7f82a72c2 | |||
| bd20ebfad7 | |||
| 71553ad67b | |||
| 41c39f1f28 |
@@ -21,7 +21,7 @@ runs:
|
|||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v5
|
||||||
|
|
||||||
- name: Setup Environment Variables
|
- name: Setup Environment Variables
|
||||||
id: variables
|
id: variables
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ description: Runs unit/integration tests
|
|||||||
inputs:
|
inputs:
|
||||||
use_postgres:
|
use_postgres:
|
||||||
description: 'Whether postgres should be used for the database'
|
description: 'Whether postgres should be used for the database'
|
||||||
|
postgres-version:
|
||||||
|
description: 'Which postgres version should be used for the database'
|
||||||
os:
|
os:
|
||||||
description: 'OS that the tests are running on'
|
description: 'OS that the tests are running on'
|
||||||
required: true
|
required: true
|
||||||
@@ -27,16 +29,18 @@ runs:
|
|||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v5
|
||||||
|
|
||||||
- name: Setup Postgres
|
- name: Setup Postgres
|
||||||
if: ${{ inputs.use_postgres }}
|
if: ${{ inputs.use_postgres }}
|
||||||
uses: ikalnytskyi/action-setup-postgres@v4
|
uses: ikalnytskyi/action-setup-postgres@v7
|
||||||
|
with:
|
||||||
|
postgres-version: ${{ inputs.postgres-version }}
|
||||||
|
|
||||||
- name: Setup Test Variables
|
- name: Setup Test Variables
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}" >> "$GITHUB_ENV"
|
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}${{ inputs.use_postgres && inputs.postgres-version && inputs.postgres-version }}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Setup Postgres Environment Variables
|
- name: Setup Postgres Environment Variables
|
||||||
if: ${{ inputs.use_postgres }}
|
if: ${{ inputs.use_postgres }}
|
||||||
@@ -48,14 +52,14 @@ runs:
|
|||||||
echo "Sonarr__Postgres__Password=postgres" >> "$GITHUB_ENV"
|
echo "Sonarr__Postgres__Password=postgres" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Download Artifact
|
- name: Download Artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: ${{ inputs.artifact }}
|
name: ${{ inputs.artifact }}
|
||||||
path: _tests
|
path: _tests
|
||||||
|
|
||||||
- name: Download Binary Artifact
|
- name: Download Binary Artifact
|
||||||
if: ${{ inputs.integration_tests }}
|
if: ${{ inputs.integration_tests }}
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: ${{ inputs.binary_artifact }}
|
name: ${{ inputs.binary_artifact }}
|
||||||
path: _output
|
path: _output
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
uses: ./.github/actions/build
|
uses: ./.github/actions/build
|
||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Volta
|
- name: Volta
|
||||||
uses: volta-cli/action@v4
|
uses: volta-cli/action@v4
|
||||||
@@ -139,7 +139,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
uses: ./.github/actions/test
|
uses: ./.github/actions/test
|
||||||
@@ -152,9 +152,13 @@ jobs:
|
|||||||
unit_test_postgres:
|
unit_test_postgres:
|
||||||
needs: backend
|
needs: backend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
postgres-version: [16, 17]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
uses: ./.github/actions/test
|
uses: ./.github/actions/test
|
||||||
@@ -164,6 +168,7 @@ jobs:
|
|||||||
pattern: Sonarr.*.Test.dll
|
pattern: Sonarr.*.Test.dll
|
||||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
|
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
|
||||||
use_postgres: true
|
use_postgres: true
|
||||||
|
postgres-version: ${{ matrix.postgres-version }}
|
||||||
|
|
||||||
integration_test:
|
integration_test:
|
||||||
needs: [prepare, backend]
|
needs: [prepare, backend]
|
||||||
@@ -190,7 +195,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
uses: ./.github/actions/test
|
uses: ./.github/actions/test
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty
|
### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty
|
||||||
### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory
|
### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory
|
||||||
### Version V1.0.4 2025-04-05 - kaecyra - Allow user/group to be supplied via CLI, add unattended mode
|
### Version V1.0.4 2025-04-05 - kaecyra - Allow user/group to be supplied via CLI, add unattended mode
|
||||||
|
### Version V1.0.5 2025-07-08 - bparkin1283 - use systemctl instead of service for stopping app
|
||||||
|
|
||||||
### Boilerplate Warning
|
### Boilerplate Warning
|
||||||
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
@@ -167,11 +168,10 @@ if ! getent group "$app_guid" | grep -qw "$app_uid"; then
|
|||||||
echo "Added User [$app_uid] to Group [$app_guid]"
|
echo "Added User [$app_uid] to Group [$app_guid]"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stop the App if running
|
# Stop and disable the App if running
|
||||||
if service --status-all | grep -Fq "$app"; then
|
if [ $(systemctl is-active "$app") = "active" ]; then
|
||||||
systemctl stop "$app"
|
systemctl disable --now -q "$app"
|
||||||
systemctl disable "$app".service
|
echo "Stopped and disabled existing $app"
|
||||||
echo "Stopped existing $app"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create Appdata Directory
|
# Create Appdata Directory
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { setQueueOptions } from 'Activity/Queue/queueOptionsStore';
|
||||||
import { SelectProvider } from 'App/SelectContext';
|
import { SelectProvider } from 'App/SelectContext';
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import * as commandNames from 'Commands/commandNames';
|
import * as commandNames from 'Commands/commandNames';
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
@@ -16,20 +16,8 @@ import Table from 'Components/Table/Table';
|
|||||||
import TableBody from 'Components/Table/TableBody';
|
import TableBody from 'Components/Table/TableBody';
|
||||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||||
import TablePager from 'Components/Table/TablePager';
|
import TablePager from 'Components/Table/TablePager';
|
||||||
import usePaging from 'Components/Table/usePaging';
|
|
||||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
|
||||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
|
||||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||||
import { align, icons, kinds } from 'Helpers/Props';
|
import { align, icons, kinds } from 'Helpers/Props';
|
||||||
import {
|
|
||||||
clearBlocklist,
|
|
||||||
fetchBlocklist,
|
|
||||||
gotoBlocklistPage,
|
|
||||||
removeBlocklistItems,
|
|
||||||
setBlocklistFilter,
|
|
||||||
setBlocklistSort,
|
|
||||||
setBlocklistTableOption,
|
|
||||||
} from 'Store/Actions/blocklistActions';
|
|
||||||
import { executeCommand } from 'Store/Actions/commandActions';
|
import { executeCommand } from 'Store/Actions/commandActions';
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||||
@@ -43,27 +31,35 @@ import {
|
|||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||||
import BlocklistFilterModal from './BlocklistFilterModal';
|
import BlocklistFilterModal from './BlocklistFilterModal';
|
||||||
|
import {
|
||||||
|
setBlocklistOption,
|
||||||
|
useBlocklistOptions,
|
||||||
|
} from './blocklistOptionsStore';
|
||||||
import BlocklistRow from './BlocklistRow';
|
import BlocklistRow from './BlocklistRow';
|
||||||
|
import useBlocklist, {
|
||||||
|
useFilters,
|
||||||
|
useRemoveBlocklistItems,
|
||||||
|
} from './useBlocklist';
|
||||||
|
|
||||||
function Blocklist() {
|
function Blocklist() {
|
||||||
const requestCurrentPage = useCurrentPage();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isFetching,
|
records,
|
||||||
isPopulated,
|
|
||||||
error,
|
|
||||||
items,
|
|
||||||
columns,
|
|
||||||
selectedFilterKey,
|
|
||||||
filters,
|
|
||||||
sortKey,
|
|
||||||
sortDirection,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalPages,
|
totalPages,
|
||||||
totalRecords,
|
totalRecords,
|
||||||
isRemoving,
|
isFetching,
|
||||||
} = useSelector((state: AppState) => state.blocklist);
|
isFetched,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
page,
|
||||||
|
goToPage,
|
||||||
|
refetch,
|
||||||
|
} = useBlocklist();
|
||||||
|
|
||||||
|
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||||
|
useBlocklistOptions();
|
||||||
|
|
||||||
|
const filters = useFilters();
|
||||||
|
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
|
||||||
|
|
||||||
const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
|
const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
|
||||||
const isClearingBlocklistExecuting = useSelector(
|
const isClearingBlocklistExecuting = useSelector(
|
||||||
@@ -82,28 +78,27 @@ function Blocklist() {
|
|||||||
return getSelectedIds(selectedState);
|
return getSelectedIds(selectedState);
|
||||||
}, [selectedState]);
|
}, [selectedState]);
|
||||||
|
|
||||||
const wasClearingBlocklistExecuting = usePrevious(
|
|
||||||
isClearingBlocklistExecuting
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectAllChange = useCallback(
|
const handleSelectAllChange = useCallback(
|
||||||
({ value }: CheckInputChanged) => {
|
({ value }: CheckInputChanged) => {
|
||||||
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
setSelectState({
|
||||||
|
type: value ? 'selectAll' : 'unselectAll',
|
||||||
|
items: records,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[items, setSelectState]
|
[records, setSelectState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectedChange = useCallback(
|
const handleSelectedChange = useCallback(
|
||||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||||
setSelectState({
|
setSelectState({
|
||||||
type: 'toggleSelected',
|
type: 'toggleSelected',
|
||||||
items,
|
items: records,
|
||||||
id,
|
id,
|
||||||
isSelected: value,
|
isSelected: value,
|
||||||
shiftKey,
|
shiftKey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[items, setSelectState]
|
[records, setSelectState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveSelectedPress = useCallback(() => {
|
const handleRemoveSelectedPress = useCallback(() => {
|
||||||
@@ -111,9 +106,9 @@ function Blocklist() {
|
|||||||
}, [setIsConfirmRemoveModalOpen]);
|
}, [setIsConfirmRemoveModalOpen]);
|
||||||
|
|
||||||
const handleRemoveSelectedConfirmed = useCallback(() => {
|
const handleRemoveSelectedConfirmed = useCallback(() => {
|
||||||
dispatch(removeBlocklistItems({ ids: selectedIds }));
|
removeBlocklistItems({ ids: selectedIds });
|
||||||
setIsConfirmRemoveModalOpen(false);
|
setIsConfirmRemoveModalOpen(false);
|
||||||
}, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]);
|
}, [selectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]);
|
||||||
|
|
||||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||||
setIsConfirmRemoveModalOpen(false);
|
setIsConfirmRemoveModalOpen(false);
|
||||||
@@ -124,66 +119,46 @@ function Blocklist() {
|
|||||||
}, [setIsConfirmClearModalOpen]);
|
}, [setIsConfirmClearModalOpen]);
|
||||||
|
|
||||||
const handleClearBlocklistConfirmed = useCallback(() => {
|
const handleClearBlocklistConfirmed = useCallback(() => {
|
||||||
dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST }));
|
dispatch(
|
||||||
|
executeCommand({
|
||||||
|
name: commandNames.CLEAR_BLOCKLIST,
|
||||||
|
commandFinished: () => {
|
||||||
|
goToPage(1);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
setIsConfirmClearModalOpen(false);
|
setIsConfirmClearModalOpen(false);
|
||||||
}, [setIsConfirmClearModalOpen, dispatch]);
|
}, [setIsConfirmClearModalOpen, goToPage, dispatch]);
|
||||||
|
|
||||||
const handleConfirmClearModalClose = useCallback(() => {
|
const handleConfirmClearModalClose = useCallback(() => {
|
||||||
setIsConfirmClearModalOpen(false);
|
setIsConfirmClearModalOpen(false);
|
||||||
}, [setIsConfirmClearModalOpen]);
|
}, [setIsConfirmClearModalOpen]);
|
||||||
|
|
||||||
const {
|
|
||||||
handleFirstPagePress,
|
|
||||||
handlePreviousPagePress,
|
|
||||||
handleNextPagePress,
|
|
||||||
handleLastPagePress,
|
|
||||||
handlePageSelect,
|
|
||||||
} = usePaging({
|
|
||||||
page,
|
|
||||||
totalPages,
|
|
||||||
gotoPage: gotoBlocklistPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleFilterSelect = useCallback(
|
const handleFilterSelect = useCallback(
|
||||||
(selectedFilterKey: string | number) => {
|
(selectedFilterKey: string | number) => {
|
||||||
dispatch(setBlocklistFilter({ selectedFilterKey }));
|
setBlocklistOption('selectedFilterKey', selectedFilterKey);
|
||||||
},
|
},
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortPress = useCallback(
|
const handleSortPress = useCallback((sortKey: string) => {
|
||||||
(sortKey: string) => {
|
setBlocklistOption('sortKey', sortKey);
|
||||||
dispatch(setBlocklistSort({ sortKey }));
|
}, []);
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTableOptionChange = useCallback(
|
const handleTableOptionChange = useCallback(
|
||||||
(payload: TableOptionsChangePayload) => {
|
(payload: TableOptionsChangePayload) => {
|
||||||
dispatch(setBlocklistTableOption(payload));
|
setQueueOptions(payload);
|
||||||
|
|
||||||
if (payload.pageSize) {
|
if (payload.pageSize) {
|
||||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
goToPage(1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[goToPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (requestCurrentPage) {
|
|
||||||
dispatch(fetchBlocklist());
|
|
||||||
} else {
|
|
||||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
dispatch(clearBlocklist());
|
|
||||||
};
|
|
||||||
}, [requestCurrentPage, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const repopulate = () => {
|
const repopulate = () => {
|
||||||
dispatch(fetchBlocklist());
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
registerPagePopulator(repopulate);
|
registerPagePopulator(repopulate);
|
||||||
@@ -191,16 +166,10 @@ function Blocklist() {
|
|||||||
return () => {
|
return () => {
|
||||||
unregisterPagePopulator(repopulate);
|
unregisterPagePopulator(repopulate);
|
||||||
};
|
};
|
||||||
}, [dispatch]);
|
}, [refetch]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
|
|
||||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
|
||||||
}
|
|
||||||
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectProvider items={items}>
|
<SelectProvider items={records}>
|
||||||
<PageContent title={translate('Blocklist')}>
|
<PageContent title={translate('Blocklist')}>
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<PageToolbarSection>
|
<PageToolbarSection>
|
||||||
@@ -215,7 +184,7 @@ function Blocklist() {
|
|||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
label={translate('Clear')}
|
label={translate('Clear')}
|
||||||
iconName={icons.CLEAR}
|
iconName={icons.CLEAR}
|
||||||
isDisabled={!items.length}
|
isDisabled={!records.length}
|
||||||
isSpinning={isClearingBlocklistExecuting}
|
isSpinning={isClearingBlocklistExecuting}
|
||||||
onPress={handleClearBlocklistPress}
|
onPress={handleClearBlocklistPress}
|
||||||
/>
|
/>
|
||||||
@@ -245,13 +214,13 @@ function Blocklist() {
|
|||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
<PageContentBody>
|
<PageContentBody>
|
||||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
{isLoading && !isFetched ? <LoadingIndicator /> : null}
|
||||||
|
|
||||||
{!isFetching && !!error ? (
|
{!isLoading && !!error ? (
|
||||||
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
|
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isPopulated && !error && !items.length ? (
|
{isFetched && !error && !records.length ? (
|
||||||
<Alert kind={kinds.INFO}>
|
<Alert kind={kinds.INFO}>
|
||||||
{selectedFilterKey === 'all'
|
{selectedFilterKey === 'all'
|
||||||
? translate('NoBlocklistItems')
|
? translate('NoBlocklistItems')
|
||||||
@@ -259,7 +228,7 @@ function Blocklist() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isPopulated && !error && !!items.length ? (
|
{isFetched && !error && !!records.length ? (
|
||||||
<div>
|
<div>
|
||||||
<Table
|
<Table
|
||||||
selectAll={true}
|
selectAll={true}
|
||||||
@@ -274,7 +243,7 @@ function Blocklist() {
|
|||||||
onSortPress={handleSortPress}
|
onSortPress={handleSortPress}
|
||||||
>
|
>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((item) => {
|
{records.map((item) => {
|
||||||
return (
|
return (
|
||||||
<BlocklistRow
|
<BlocklistRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -292,11 +261,7 @@ function Blocklist() {
|
|||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalRecords={totalRecords}
|
totalRecords={totalRecords}
|
||||||
isFetching={isFetching}
|
isFetching={isFetching}
|
||||||
onFirstPagePress={handleFirstPagePress}
|
onPageSelect={goToPage}
|
||||||
onPreviousPagePress={handlePreviousPagePress}
|
|
||||||
onNextPagePress={handleNextPagePress}
|
|
||||||
onLastPagePress={handleLastPagePress}
|
|
||||||
onPageSelect={handlePageSelect}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -16,13 +16,19 @@ interface BlocklistDetailsModalProps {
|
|||||||
protocol: DownloadProtocol;
|
protocol: DownloadProtocol;
|
||||||
indexer?: string;
|
indexer?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
source?: string;
|
||||||
onModalClose: () => void;
|
onModalClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
|
function BlocklistDetailsModal({
|
||||||
const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } =
|
isOpen,
|
||||||
props;
|
sourceTitle,
|
||||||
|
protocol,
|
||||||
|
indexer,
|
||||||
|
message,
|
||||||
|
source,
|
||||||
|
onModalClose,
|
||||||
|
}: BlocklistDetailsModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||||
<ModalContent onModalClose={onModalClose}>
|
<ModalContent onModalClose={onModalClose}>
|
||||||
@@ -50,6 +56,9 @@ function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
|
|||||||
data={message}
|
data={message}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{source ? (
|
||||||
|
<DescriptionListItem title={translate('Source')} data={source} />
|
||||||
|
) : null}
|
||||||
</DescriptionList>
|
</DescriptionList>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,26 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||||
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
|
import { setBlocklistOption } from './blocklistOptionsStore';
|
||||||
|
import useBlocklist, { FILTER_BUILDER } from './useBlocklist';
|
||||||
function createBlocklistSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.blocklist.items,
|
|
||||||
(blocklistItems) => {
|
|
||||||
return blocklistItems;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFilterBuilderPropsSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.blocklist.filterBuilderProps,
|
|
||||||
(filterBuilderProps) => {
|
|
||||||
return filterBuilderProps;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlocklistFilterModalProps = FilterModalProps<History>;
|
type BlocklistFilterModalProps = FilterModalProps<History>;
|
||||||
|
|
||||||
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
||||||
const sectionItems = useSelector(createBlocklistSelector());
|
const { records } = useBlocklist();
|
||||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
|
||||||
const customFilterType = 'blocklist';
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const dispatchSetFilter = useCallback(
|
const dispatchSetFilter = useCallback(
|
||||||
(payload: unknown) => {
|
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||||
dispatch(setBlocklistFilter(payload));
|
setBlocklistOption('selectedFilterKey', selectedFilterKey);
|
||||||
},
|
},
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterModal
|
<FilterModal
|
||||||
{...props}
|
{...props}
|
||||||
sectionItems={sectionItems}
|
sectionItems={records}
|
||||||
filterBuilderProps={filterBuilderProps}
|
filterBuilderProps={FILTER_BUILDER}
|
||||||
customFilterType={customFilterType}
|
customFilterType="blocklist"
|
||||||
dispatchSetFilter={dispatchSetFilter}
|
dispatchSetFilter={dispatchSetFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
@@ -12,11 +11,11 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
|
|||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||||
import useSeries from 'Series/useSeries';
|
import useSeries from 'Series/useSeries';
|
||||||
import { removeBlocklistItem } from 'Store/Actions/blocklistActions';
|
|
||||||
import Blocklist from 'typings/Blocklist';
|
import Blocklist from 'typings/Blocklist';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
import { SelectStateInputProps } from 'typings/props';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import BlocklistDetailsModal from './BlocklistDetailsModal';
|
import BlocklistDetailsModal from './BlocklistDetailsModal';
|
||||||
|
import { useRemoveBlocklistItem } from './useBlocklist';
|
||||||
import styles from './BlocklistRow.css';
|
import styles from './BlocklistRow.css';
|
||||||
|
|
||||||
interface BlocklistRowProps extends Blocklist {
|
interface BlocklistRowProps extends Blocklist {
|
||||||
@@ -25,25 +24,24 @@ interface BlocklistRowProps extends Blocklist {
|
|||||||
onSelectedChange: (options: SelectStateInputProps) => void;
|
onSelectedChange: (options: SelectStateInputProps) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlocklistRow(props: BlocklistRowProps) {
|
function BlocklistRow({
|
||||||
const {
|
id,
|
||||||
id,
|
seriesId,
|
||||||
seriesId,
|
sourceTitle,
|
||||||
sourceTitle,
|
languages,
|
||||||
languages,
|
quality,
|
||||||
quality,
|
customFormats,
|
||||||
customFormats,
|
date,
|
||||||
date,
|
protocol,
|
||||||
protocol,
|
indexer,
|
||||||
indexer,
|
message,
|
||||||
message,
|
source,
|
||||||
isSelected,
|
isSelected,
|
||||||
columns,
|
columns,
|
||||||
onSelectedChange,
|
onSelectedChange,
|
||||||
} = props;
|
}: BlocklistRowProps) {
|
||||||
|
|
||||||
const series = useSeries(seriesId);
|
const series = useSeries(seriesId);
|
||||||
const dispatch = useDispatch();
|
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
|
||||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleDetailsPress = useCallback(() => {
|
const handleDetailsPress = useCallback(() => {
|
||||||
@@ -55,8 +53,8 @@ function BlocklistRow(props: BlocklistRowProps) {
|
|||||||
}, [setIsDetailsModalOpen]);
|
}, [setIsDetailsModalOpen]);
|
||||||
|
|
||||||
const handleRemovePress = useCallback(() => {
|
const handleRemovePress = useCallback(() => {
|
||||||
dispatch(removeBlocklistItem({ id }));
|
removeBlocklistItem();
|
||||||
}, [id, dispatch]);
|
}, [removeBlocklistItem]);
|
||||||
|
|
||||||
if (!series) {
|
if (!series) {
|
||||||
return null;
|
return null;
|
||||||
@@ -139,6 +137,7 @@ function BlocklistRow(props: BlocklistRowProps) {
|
|||||||
title={translate('RemoveFromBlocklist')}
|
title={translate('RemoveFromBlocklist')}
|
||||||
name={icons.REMOVE}
|
name={icons.REMOVE}
|
||||||
kind={kinds.DANGER}
|
kind={kinds.DANGER}
|
||||||
|
isSpinning={isRemoving}
|
||||||
onPress={handleRemovePress}
|
onPress={handleRemovePress}
|
||||||
/>
|
/>
|
||||||
</TableRowCell>
|
</TableRowCell>
|
||||||
@@ -154,6 +153,7 @@ function BlocklistRow(props: BlocklistRowProps) {
|
|||||||
protocol={protocol}
|
protocol={protocol}
|
||||||
indexer={indexer}
|
indexer={indexer}
|
||||||
message={message}
|
message={message}
|
||||||
|
source={source}
|
||||||
onModalClose={handleDetailsModalClose}
|
onModalClose={handleDetailsModalClose}
|
||||||
/>
|
/>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
createOptionsStore,
|
||||||
|
PageableOptions,
|
||||||
|
} from 'Helpers/Hooks/useOptionsStore';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
|
export type BlocklistOptions = PageableOptions;
|
||||||
|
|
||||||
|
const { useOptions, useOption, setOptions, setOption } =
|
||||||
|
createOptionsStore<BlocklistOptions>('blocklist_options', () => {
|
||||||
|
return {
|
||||||
|
pageSize: 20,
|
||||||
|
selectedFilterKey: 'all',
|
||||||
|
sortKey: 'time',
|
||||||
|
sortDirection: 'descending',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'series.sortTitle',
|
||||||
|
label: () => translate('SeriesTitle'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sourceTitle',
|
||||||
|
label: () => translate('SourceTitle'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'languages',
|
||||||
|
label: () => translate('Languages'),
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'quality',
|
||||||
|
label: () => translate('Quality'),
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'customFormats',
|
||||||
|
label: () => translate('Formats'),
|
||||||
|
isSortable: false,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'date',
|
||||||
|
label: () => translate('Date'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'indexer',
|
||||||
|
label: () => translate('Indexer'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'actions',
|
||||||
|
label: '',
|
||||||
|
columnLabel: () => translate('Actions'),
|
||||||
|
isVisible: true,
|
||||||
|
isModifiable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useBlocklistOptions = useOptions;
|
||||||
|
export const setBlocklistOptions = setOptions;
|
||||||
|
export const useBlocklistOption = useOption;
|
||||||
|
export const setBlocklistOption = setOption;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
||||||
|
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||||
|
import usePage from 'Helpers/Hooks/usePage';
|
||||||
|
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||||
|
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||||
|
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||||
|
import Blocklist from 'typings/Blocklist';
|
||||||
|
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import { useBlocklistOptions } from './blocklistOptionsStore';
|
||||||
|
|
||||||
|
interface BulkBlocklistData {
|
||||||
|
ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FILTERS: Filter[] = [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: () => translate('All'),
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FILTER_BUILDER: FilterBuilderProp<Blocklist>[] = [
|
||||||
|
{
|
||||||
|
name: 'seriesIds',
|
||||||
|
label: () => translate('Series'),
|
||||||
|
type: 'equal',
|
||||||
|
valueType: filterBuilderValueTypes.SERIES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'protocols',
|
||||||
|
label: () => translate('Protocol'),
|
||||||
|
type: 'equal',
|
||||||
|
valueType: filterBuilderValueTypes.PROTOCOL,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useBlocklist = () => {
|
||||||
|
const { page, goToPage } = usePage('blocklist');
|
||||||
|
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||||
|
useBlocklistOptions();
|
||||||
|
const customFilters = useSelector(
|
||||||
|
createCustomFiltersSelector('blocklist')
|
||||||
|
) as CustomFilter[];
|
||||||
|
|
||||||
|
const filters = useMemo(() => {
|
||||||
|
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||||
|
}, [selectedFilterKey, customFilters]);
|
||||||
|
|
||||||
|
const { refetch, ...query } = usePagedApiQuery<Blocklist>({
|
||||||
|
path: '/blocklist',
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
filters,
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
queryOptions: {
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
goToPage,
|
||||||
|
page,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useBlocklist;
|
||||||
|
|
||||||
|
export const useFilters = () => {
|
||||||
|
return FILTERS;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoveBlocklistItem = (id: number) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||||
|
path: `/blocklist/${id}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
mutationOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
removeBlocklistItem: mutate,
|
||||||
|
isRemoving: isPending,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoveBlocklistItems = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { mutate, isPending } = useApiMutation<unknown, BulkBlocklistData>({
|
||||||
|
path: `/blocklist/bulk`,
|
||||||
|
method: 'DELETE',
|
||||||
|
mutationOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
removeBlocklistItems: mutate,
|
||||||
|
isRemoving: isPending,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -174,7 +174,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === 'downloadFailed') {
|
if (eventType === 'downloadFailed') {
|
||||||
const { message, indexer } = data as DownloadFailedHistory;
|
const { indexer, message, source } = data as DownloadFailedHistory;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DescriptionList>
|
<DescriptionList>
|
||||||
@@ -195,6 +195,10 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||||||
{message ? (
|
{message ? (
|
||||||
<DescriptionListItem title={translate('Message')} data={message} />
|
<DescriptionListItem title={translate('Message')} data={message} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{source ? (
|
||||||
|
<DescriptionListItem title={translate('Source')} data={source} />
|
||||||
|
) : null}
|
||||||
</DescriptionList>
|
</DescriptionList>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect, useRef } 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,6 +9,7 @@ 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';
|
||||||
|
|
||||||
@@ -33,26 +34,32 @@ 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 {
|
const { isOpen, id, eventType, sourceTitle, data, downloadId, onModalClose } =
|
||||||
isOpen,
|
props;
|
||||||
eventType,
|
|
||||||
sourceTitle,
|
const { markAsFailed, isMarkingAsFailed, markAsFailedError } =
|
||||||
data,
|
useMarkAsFailed(id);
|
||||||
downloadId,
|
|
||||||
isMarkingAsFailed = false,
|
const wasMarkingAsFailed = useRef(isMarkingAsFailed);
|
||||||
onMarkAsFailedPress,
|
|
||||||
onModalClose,
|
const handleMarkAsFailedPress = useCallback(() => {
|
||||||
} = props;
|
markAsFailed();
|
||||||
|
}, [markAsFailed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
|
||||||
|
onModalClose();
|
||||||
|
}
|
||||||
|
}, [wasMarkingAsFailed, isMarkingAsFailed, markAsFailedError, onModalClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||||
@@ -74,7 +81,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
|||||||
className={styles.markAsFailedButton}
|
className={styles.markAsFailedButton}
|
||||||
kind={kinds.DANGER}
|
kind={kinds.DANGER}
|
||||||
isSpinning={isMarkingAsFailed}
|
isSpinning={isMarkingAsFailed}
|
||||||
onPress={onMarkAsFailedPress}
|
onPress={handleMarkAsFailedPress}
|
||||||
>
|
>
|
||||||
{translate('MarkAsFailed')}
|
{translate('MarkAsFailed')}
|
||||||
</SpinnerButton>
|
</SpinnerButton>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import AppState from 'App/State/AppState';
|
import {
|
||||||
|
setQueueOption,
|
||||||
|
setQueueOptions,
|
||||||
|
} from 'Activity/Queue/queueOptionsStore';
|
||||||
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';
|
||||||
@@ -13,20 +16,11 @@ import Table from 'Components/Table/Table';
|
|||||||
import TableBody from 'Components/Table/TableBody';
|
import TableBody from 'Components/Table/TableBody';
|
||||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||||
import TablePager from 'Components/Table/TablePager';
|
import TablePager from 'Components/Table/TablePager';
|
||||||
import usePaging from 'Components/Table/usePaging';
|
|
||||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
||||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||||
import { align, icons, kinds } from 'Helpers/Props';
|
import { align, icons, kinds } from 'Helpers/Props';
|
||||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||||
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
||||||
import {
|
|
||||||
clearHistory,
|
|
||||||
fetchHistory,
|
|
||||||
gotoHistoryPage,
|
|
||||||
setHistoryFilter,
|
|
||||||
setHistorySort,
|
|
||||||
setHistoryTableOption,
|
|
||||||
} from 'Store/Actions/historyActions';
|
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
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';
|
||||||
@@ -37,100 +31,90 @@ 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 { 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 {
|
||||||
isFetching,
|
records,
|
||||||
isPopulated,
|
|
||||||
error,
|
|
||||||
items,
|
|
||||||
columns,
|
|
||||||
selectedFilterKey,
|
|
||||||
filters,
|
|
||||||
sortKey,
|
|
||||||
sortDirection,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalPages,
|
totalPages,
|
||||||
totalRecords,
|
totalRecords,
|
||||||
} = useSelector((state: AppState) => state.history);
|
error,
|
||||||
|
isFetching,
|
||||||
|
isFetched,
|
||||||
|
isLoading,
|
||||||
|
page,
|
||||||
|
goToPage,
|
||||||
|
refetch,
|
||||||
|
} = useHistory();
|
||||||
|
|
||||||
|
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||||
|
useHistoryOptions();
|
||||||
|
|
||||||
|
const filters = useFilters();
|
||||||
|
|
||||||
|
const requestCurrentPage = useCurrentPage();
|
||||||
|
|
||||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
||||||
useSelector(createEpisodesFetchingSelector());
|
useSelector(createEpisodesFetchingSelector());
|
||||||
const customFilters = useSelector(createCustomFiltersSelector('history'));
|
const customFilters = useSelector(createCustomFiltersSelector('history'));
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const isFetchingAny = isFetching || isEpisodesFetching;
|
const isFetchingAny = isLoading || isEpisodesFetching;
|
||||||
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
|
const isAllPopulated = isFetched && (isEpisodesPopulated || !records.length);
|
||||||
const hasError = error || episodesError;
|
const hasError = error || episodesError;
|
||||||
|
|
||||||
const {
|
|
||||||
handleFirstPagePress,
|
|
||||||
handlePreviousPagePress,
|
|
||||||
handleNextPagePress,
|
|
||||||
handleLastPagePress,
|
|
||||||
handlePageSelect,
|
|
||||||
} = usePaging({
|
|
||||||
page,
|
|
||||||
totalPages,
|
|
||||||
gotoPage: gotoHistoryPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleFilterSelect = useCallback(
|
const handleFilterSelect = useCallback(
|
||||||
(selectedFilterKey: string | number) => {
|
(selectedFilterKey: string | number) => {
|
||||||
dispatch(setHistoryFilter({ selectedFilterKey }));
|
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||||
},
|
},
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortPress = useCallback(
|
const handleSortPress = useCallback((sortKey: string) => {
|
||||||
(sortKey: string) => {
|
setQueueOption('sortKey', sortKey);
|
||||||
dispatch(setHistorySort({ sortKey }));
|
}, []);
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTableOptionChange = useCallback(
|
const handleTableOptionChange = useCallback(
|
||||||
(payload: TableOptionsChangePayload) => {
|
(payload: TableOptionsChangePayload) => {
|
||||||
dispatch(setHistoryTableOption(payload));
|
setQueueOptions(payload);
|
||||||
|
|
||||||
if (payload.pageSize) {
|
if (payload.pageSize) {
|
||||||
dispatch(gotoHistoryPage({ page: 1 }));
|
goToPage(1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[goToPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleRefreshPress = useCallback(() => {
|
||||||
if (requestCurrentPage) {
|
goToPage(1);
|
||||||
dispatch(fetchHistory());
|
refetch();
|
||||||
} else {
|
}, [goToPage, refetch]);
|
||||||
dispatch(gotoHistoryPage({ page: 1 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
dispatch(clearHistory());
|
|
||||||
dispatch(clearEpisodes());
|
dispatch(clearEpisodes());
|
||||||
dispatch(clearEpisodeFiles());
|
dispatch(clearEpisodeFiles());
|
||||||
};
|
};
|
||||||
}, [requestCurrentPage, dispatch]);
|
}, [requestCurrentPage, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const episodeIds = selectUniqueIds<HistoryItem, number>(items, 'episodeId');
|
const episodeIds = selectUniqueIds<HistoryItem, number>(
|
||||||
|
records,
|
||||||
|
'episodeId'
|
||||||
|
);
|
||||||
|
|
||||||
if (episodeIds.length) {
|
if (episodeIds.length) {
|
||||||
dispatch(fetchEpisodes({ episodeIds }));
|
dispatch(fetchEpisodes({ episodeIds }));
|
||||||
} else {
|
} else {
|
||||||
dispatch(clearEpisodes());
|
dispatch(clearEpisodes());
|
||||||
}
|
}
|
||||||
}, [items, dispatch]);
|
}, [records, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const repopulate = () => {
|
const repopulate = () => {
|
||||||
dispatch(fetchHistory());
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
registerPagePopulator(repopulate);
|
registerPagePopulator(repopulate);
|
||||||
@@ -138,7 +122,7 @@ function History() {
|
|||||||
return () => {
|
return () => {
|
||||||
unregisterPagePopulator(repopulate);
|
unregisterPagePopulator(repopulate);
|
||||||
};
|
};
|
||||||
}, [dispatch]);
|
}, [refetch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent title={translate('History')}>
|
<PageContent title={translate('History')}>
|
||||||
@@ -148,7 +132,7 @@ function History() {
|
|||||||
label={translate('Refresh')}
|
label={translate('Refresh')}
|
||||||
iconName={icons.REFRESH}
|
iconName={icons.REFRESH}
|
||||||
isSpinning={isFetching}
|
isSpinning={isFetching}
|
||||||
onPress={handleFirstPagePress}
|
onPress={handleRefreshPress}
|
||||||
/>
|
/>
|
||||||
</PageToolbarSection>
|
</PageToolbarSection>
|
||||||
|
|
||||||
@@ -186,12 +170,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.
|
||||||
|
|
||||||
isPopulated && !hasError && !items.length ? (
|
isFetched && !hasError && !records.length ? (
|
||||||
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
|
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
{isAllPopulated && !hasError && items.length ? (
|
{isAllPopulated && !hasError && records.length ? (
|
||||||
<div>
|
<div>
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -202,7 +186,7 @@ function History() {
|
|||||||
onSortPress={handleSortPress}
|
onSortPress={handleSortPress}
|
||||||
>
|
>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((item) => {
|
{records.map((item) => {
|
||||||
return (
|
return (
|
||||||
<HistoryRow key={item.id} columns={columns} {...item} />
|
<HistoryRow key={item.id} columns={columns} {...item} />
|
||||||
);
|
);
|
||||||
@@ -215,11 +199,7 @@ function History() {
|
|||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalRecords={totalRecords}
|
totalRecords={totalRecords}
|
||||||
isFetching={isFetching}
|
isFetching={isFetching}
|
||||||
onFirstPagePress={handleFirstPagePress}
|
onPageSelect={goToPage}
|
||||||
onPreviousPagePress={handlePreviousPagePress}
|
|
||||||
onNextPagePress={handleNextPagePress}
|
|
||||||
onLastPagePress={handleLastPagePress}
|
|
||||||
onPageSelect={handlePageSelect}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,48 +1,25 @@
|
|||||||
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 { setHistoryFilter } from 'Store/Actions/historyActions';
|
import { setHistoryOption } from './historyOptionsStore';
|
||||||
|
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 sectionItems = useSelector(createHistorySelector());
|
const { records } = useHistory();
|
||||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const dispatchSetFilter = useCallback(
|
const dispatchSetFilter = useCallback(
|
||||||
(payload: { selectedFilterKey: string | number }) => {
|
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||||
dispatch(setHistoryFilter(payload));
|
setHistoryOption('selectedFilterKey', selectedFilterKey);
|
||||||
},
|
},
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterModal
|
<FilterModal
|
||||||
{...props}
|
{...props}
|
||||||
sectionItems={sectionItems}
|
sectionItems={records}
|
||||||
filterBuilderProps={filterBuilderProps}
|
filterBuilderProps={FILTER_BUILDER}
|
||||||
customFilterType="history"
|
customFilterType="history"
|
||||||
dispatchSetFilter={dispatchSetFilter}
|
dispatchSetFilter={dispatchSetFilter}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
@@ -13,13 +12,11 @@ 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 useSeries 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';
|
||||||
@@ -61,13 +58,9 @@ function HistoryRow(props: HistoryRowProps) {
|
|||||||
date,
|
date,
|
||||||
data,
|
data,
|
||||||
downloadId,
|
downloadId,
|
||||||
isMarkingAsFailed = false,
|
|
||||||
markAsFailedError,
|
|
||||||
columns,
|
columns,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const wasMarkingAsFailed = usePrevious(isMarkingAsFailed);
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const series = useSeries(seriesId);
|
const series = useSeries(seriesId);
|
||||||
const episode = useEpisode(episodeId, 'episodes');
|
const episode = useEpisode(episodeId, 'episodes');
|
||||||
|
|
||||||
@@ -81,23 +74,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -254,13 +230,12 @@ 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>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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 } =
|
||||||
|
createOptionsStore<HistoryOptions>('history_options', () => {
|
||||||
|
return {
|
||||||
|
includeUnknownSeriesItems: true,
|
||||||
|
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;
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
||||||
|
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||||
|
import usePage from 'Helpers/Hooks/usePage';
|
||||||
|
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||||
|
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||||
|
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||||
|
import 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useHistory = () => {
|
||||||
|
const { page, goToPage } = usePage('history');
|
||||||
|
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||||
|
useHistoryOptions();
|
||||||
|
const customFilters = useSelector(
|
||||||
|
createCustomFiltersSelector('history')
|
||||||
|
) as CustomFilter[];
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/history'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setError('Error marking history item as failed');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
markAsFailed: mutate,
|
||||||
|
isMarkingAsFailed: isPending,
|
||||||
|
markAsFailedError: error,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
|
||||||
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
|
import Queue from 'typings/Queue';
|
||||||
|
|
||||||
|
interface EpisodeDetails {
|
||||||
|
episodeIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeriesDetails {
|
||||||
|
seriesId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AllDetails {
|
||||||
|
all: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
|
||||||
|
|
||||||
|
interface QueueDetailsProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
|
||||||
|
|
||||||
|
export default function QueueDetailsProvider({
|
||||||
|
children,
|
||||||
|
...filter
|
||||||
|
}: QueueDetailsProps & QueueDetailsFilter) {
|
||||||
|
const { data } = useApiQuery<Queue[]>({
|
||||||
|
path: '/queue/details',
|
||||||
|
queryParams: { ...filter },
|
||||||
|
queryOptions: {
|
||||||
|
enabled: Object.keys(filter).length > 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueueDetailsContext.Provider value={data}>
|
||||||
|
{children}
|
||||||
|
</QueueDetailsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQueueItemForEpisode(episodeId: number) {
|
||||||
|
const queue = useContext(QueueDetailsContext);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return queue?.find((item) => item.episodeIds.includes(episodeId));
|
||||||
|
}, [episodeId, queue]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsDownloadingEpisodes(episodeIds: number[]) {
|
||||||
|
const queue = useContext(QueueDetailsContext);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!queue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue.some((item) =>
|
||||||
|
item.episodeIds?.some((e) => episodeIds.includes(e))
|
||||||
|
);
|
||||||
|
}, [episodeIds, queue]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesQueueDetails {
|
||||||
|
count: number;
|
||||||
|
episodesWithFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQueueDetailsForSeries(
|
||||||
|
seriesId: number,
|
||||||
|
seasonNumber?: number
|
||||||
|
) {
|
||||||
|
const queue = useContext(QueueDetailsContext);
|
||||||
|
|
||||||
|
return useMemo<SeriesQueueDetails>(() => {
|
||||||
|
if (!queue) {
|
||||||
|
return { count: 0, episodesWithFiles: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue.reduce<SeriesQueueDetails>(
|
||||||
|
(acc: SeriesQueueDetails, item) => {
|
||||||
|
if (
|
||||||
|
item.trackedDownloadState === 'imported' ||
|
||||||
|
item.seriesId !== seriesId
|
||||||
|
) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.count++;
|
||||||
|
|
||||||
|
if (item.episodeHasFile) {
|
||||||
|
acc.episodesWithFiles++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: 0,
|
||||||
|
episodesWithFiles: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [seriesId, seasonNumber, queue]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useQueueDetails = () => {
|
||||||
|
return useContext(QueueDetailsContext) ?? [];
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Episode from 'Episode/Episode';
|
||||||
|
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||||
|
import Series from 'Series/Series';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
|
interface EpisodeCellContentProps {
|
||||||
|
episodes: Episode[];
|
||||||
|
isFullSeason: boolean;
|
||||||
|
seasonNumber?: number;
|
||||||
|
series?: Series;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EpisodeCellContent({
|
||||||
|
episodes,
|
||||||
|
isFullSeason,
|
||||||
|
seasonNumber,
|
||||||
|
series,
|
||||||
|
}: EpisodeCellContentProps) {
|
||||||
|
if (episodes.length === 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFullSeason && seasonNumber != null) {
|
||||||
|
return translate('SeasonNumberToken', { seasonNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodes.length === 1) {
|
||||||
|
const episode = episodes[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SeasonEpisodeNumber
|
||||||
|
seasonNumber={episode.seasonNumber}
|
||||||
|
episodeNumber={episode.episodeNumber}
|
||||||
|
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
||||||
|
seriesType={series?.seriesType}
|
||||||
|
alternateTitles={series?.alternateTitles}
|
||||||
|
sceneSeasonNumber={episode.sceneSeasonNumber}
|
||||||
|
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
||||||
|
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
|
||||||
|
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstEpisode = episodes[0];
|
||||||
|
const lastEpisode = episodes[episodes.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SeasonEpisodeNumber
|
||||||
|
seasonNumber={firstEpisode.seasonNumber}
|
||||||
|
episodeNumber={firstEpisode.episodeNumber}
|
||||||
|
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
|
||||||
|
seriesType={series?.seriesType}
|
||||||
|
alternateTitles={series?.alternateTitles}
|
||||||
|
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
|
||||||
|
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
|
||||||
|
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
|
||||||
|
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
|
||||||
|
/>
|
||||||
|
{' - '}
|
||||||
|
<SeasonEpisodeNumber
|
||||||
|
seasonNumber={lastEpisode.seasonNumber}
|
||||||
|
episodeNumber={lastEpisode.episodeNumber}
|
||||||
|
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
|
||||||
|
seriesType={series?.seriesType}
|
||||||
|
alternateTitles={series?.alternateTitles}
|
||||||
|
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
|
||||||
|
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
|
||||||
|
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
|
||||||
|
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.multiple {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodeNumber {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
'episodeNumber': string;
|
||||||
|
'multiple': string;
|
||||||
|
'row': string;
|
||||||
|
}
|
||||||
|
export const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Popover from 'Components/Tooltip/Popover';
|
||||||
|
import Episode from 'Episode/Episode';
|
||||||
|
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||||
|
import Series from 'Series/Series';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import styles from './EpisodeTitleCellContent.css';
|
||||||
|
|
||||||
|
interface EpisodeTitleCellContentProps {
|
||||||
|
episodes: Episode[];
|
||||||
|
series?: Series;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EpisodeTitleCellContent({
|
||||||
|
episodes,
|
||||||
|
series,
|
||||||
|
}: EpisodeTitleCellContentProps) {
|
||||||
|
if (episodes.length === 0 || !series) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodes.length === 1) {
|
||||||
|
const episode = episodes[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EpisodeTitleLink
|
||||||
|
episodeId={episode.id}
|
||||||
|
seriesId={series.id}
|
||||||
|
episodeTitle={episode.title}
|
||||||
|
episodeEntity="episodes"
|
||||||
|
showOpenSeriesButton={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
anchor={
|
||||||
|
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
|
||||||
|
}
|
||||||
|
title={translate('EpisodeTitles')}
|
||||||
|
body={
|
||||||
|
<>
|
||||||
|
{episodes.map((episode) => {
|
||||||
|
return (
|
||||||
|
<div key={episode.id} className={styles.row}>
|
||||||
|
<div className={styles.episodeNumber}>
|
||||||
|
{episode.episodeNumber}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EpisodeTitleLink
|
||||||
|
episodeId={episode.id}
|
||||||
|
seriesId={series.id}
|
||||||
|
episodeTitle={episode.title}
|
||||||
|
episodeEntity="episodes"
|
||||||
|
showOpenSeriesButton={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
position="right"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import * as commandNames from 'Commands/commandNames';
|
import * as commandNames from 'Commands/commandNames';
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
@@ -22,28 +21,15 @@ import Table from 'Components/Table/Table';
|
|||||||
import TableBody from 'Components/Table/TableBody';
|
import TableBody from 'Components/Table/TableBody';
|
||||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||||
import TablePager from 'Components/Table/TablePager';
|
import TablePager from 'Components/Table/TablePager';
|
||||||
import usePaging from 'Components/Table/usePaging';
|
|
||||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
||||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
|
||||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||||
import { align, icons, kinds } from 'Helpers/Props';
|
import { align, icons, kinds } from 'Helpers/Props';
|
||||||
import { executeCommand } from 'Store/Actions/commandActions';
|
import { executeCommand } from 'Store/Actions/commandActions';
|
||||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||||
import {
|
|
||||||
clearQueue,
|
|
||||||
fetchQueue,
|
|
||||||
gotoQueuePage,
|
|
||||||
grabQueueItems,
|
|
||||||
removeQueueItems,
|
|
||||||
setQueueFilter,
|
|
||||||
setQueueSort,
|
|
||||||
setQueueTableOption,
|
|
||||||
} from 'Store/Actions/queueActions';
|
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||||
import { CheckInputChanged } from 'typings/inputs';
|
import { CheckInputChanged } from 'typings/inputs';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
import { SelectStateInputProps } from 'typings/props';
|
||||||
import QueueItem from 'typings/Queue';
|
|
||||||
import { TableOptionsChangePayload } from 'typings/Table';
|
import { TableOptionsChangePayload } from 'typings/Table';
|
||||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||||
import {
|
import {
|
||||||
@@ -54,33 +40,45 @@ import translate from 'Utilities/String/translate';
|
|||||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||||
import QueueFilterModal from './QueueFilterModal';
|
import QueueFilterModal from './QueueFilterModal';
|
||||||
import QueueOptions from './QueueOptions';
|
import QueueOptions from './QueueOptions';
|
||||||
|
import {
|
||||||
|
setQueueOption,
|
||||||
|
setQueueOptions,
|
||||||
|
useQueueOptions,
|
||||||
|
} from './queueOptionsStore';
|
||||||
import QueueRow from './QueueRow';
|
import QueueRow from './QueueRow';
|
||||||
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
||||||
import createQueueStatusSelector from './Status/createQueueStatusSelector';
|
import useQueueStatus from './Status/useQueueStatus';
|
||||||
|
import useQueue, {
|
||||||
|
useFilters,
|
||||||
|
useGrabQueueItems,
|
||||||
|
useRemoveQueueItems,
|
||||||
|
} from './useQueue';
|
||||||
|
|
||||||
function Queue() {
|
function Queue() {
|
||||||
const requestCurrentPage = useCurrentPage();
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isFetching,
|
records,
|
||||||
isPopulated,
|
|
||||||
error,
|
|
||||||
items,
|
|
||||||
columns,
|
|
||||||
selectedFilterKey,
|
|
||||||
filters,
|
|
||||||
sortKey,
|
|
||||||
sortDirection,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalPages,
|
totalPages,
|
||||||
totalRecords,
|
totalRecords,
|
||||||
isGrabbing,
|
error,
|
||||||
isRemoving,
|
isFetching,
|
||||||
} = useSelector((state: AppState) => state.queue.paged);
|
isFetched,
|
||||||
|
isLoading,
|
||||||
|
page,
|
||||||
|
goToPage,
|
||||||
|
refetch,
|
||||||
|
} = useQueue();
|
||||||
|
|
||||||
const { count } = useSelector(createQueueStatusSelector());
|
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||||
|
useQueueOptions();
|
||||||
|
|
||||||
|
const filters = useFilters();
|
||||||
|
|
||||||
|
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
|
||||||
|
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
|
||||||
|
|
||||||
|
const { count } = useQueueStatus();
|
||||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
||||||
useSelector(createEpisodesFetchingSelector());
|
useSelector(createEpisodesFetchingSelector());
|
||||||
const customFilters = useSelector(createCustomFiltersSelector('queue'));
|
const customFilters = useSelector(createCustomFiltersSelector('queue'));
|
||||||
@@ -100,41 +98,46 @@ function Queue() {
|
|||||||
}, [selectedState]);
|
}, [selectedState]);
|
||||||
|
|
||||||
const isPendingSelected = useMemo(() => {
|
const isPendingSelected = useMemo(() => {
|
||||||
return items.some((item) => {
|
return records.some((item) => {
|
||||||
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
|
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
|
||||||
});
|
});
|
||||||
}, [items, selectedIds]);
|
}, [records, selectedIds]);
|
||||||
|
|
||||||
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const isRefreshing =
|
const isRefreshing =
|
||||||
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||||
const isAllPopulated =
|
const isAllPopulated =
|
||||||
isPopulated &&
|
isFetched &&
|
||||||
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
|
(isEpisodesPopulated ||
|
||||||
|
!records.length ||
|
||||||
|
records.every((e) => !e.episodeIds?.length));
|
||||||
const hasError = error || episodesError;
|
const hasError = error || episodesError;
|
||||||
const selectedCount = selectedIds.length;
|
const selectedCount = selectedIds.length;
|
||||||
const disableSelectedActions = selectedCount === 0;
|
const disableSelectedActions = selectedCount === 0;
|
||||||
|
|
||||||
const handleSelectAllChange = useCallback(
|
const handleSelectAllChange = useCallback(
|
||||||
({ value }: CheckInputChanged) => {
|
({ value }: CheckInputChanged) => {
|
||||||
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
setSelectState({
|
||||||
|
type: value ? 'selectAll' : 'unselectAll',
|
||||||
|
items: records,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[items, setSelectState]
|
[records, setSelectState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectedChange = useCallback(
|
const handleSelectedChange = useCallback(
|
||||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||||
setSelectState({
|
setSelectState({
|
||||||
type: 'toggleSelected',
|
type: 'toggleSelected',
|
||||||
items,
|
items: records,
|
||||||
id,
|
id,
|
||||||
isSelected: value,
|
isSelected: value,
|
||||||
shiftKey,
|
shiftKey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[items, setSelectState]
|
[records, setSelectState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefreshPress = useCallback(() => {
|
const handleRefreshPress = useCallback(() => {
|
||||||
@@ -150,93 +153,60 @@ function Queue() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleGrabSelectedPress = useCallback(() => {
|
const handleGrabSelectedPress = useCallback(() => {
|
||||||
dispatch(grabQueueItems({ ids: selectedIds }));
|
grabQueueItems({ ids: selectedIds });
|
||||||
}, [selectedIds, dispatch]);
|
}, [selectedIds, grabQueueItems]);
|
||||||
|
|
||||||
const handleRemoveSelectedPress = useCallback(() => {
|
const handleRemoveSelectedPress = useCallback(() => {
|
||||||
shouldBlockRefresh.current = true;
|
shouldBlockRefresh.current = true;
|
||||||
setIsConfirmRemoveModalOpen(true);
|
setIsConfirmRemoveModalOpen(true);
|
||||||
}, [setIsConfirmRemoveModalOpen]);
|
}, [setIsConfirmRemoveModalOpen]);
|
||||||
|
|
||||||
const handleRemoveSelectedConfirmed = useCallback(
|
const handleRemoveSelectedConfirmed = useCallback(() => {
|
||||||
(payload: RemovePressProps) => {
|
shouldBlockRefresh.current = false;
|
||||||
shouldBlockRefresh.current = false;
|
removeQueueItems({ ids: selectedIds });
|
||||||
dispatch(removeQueueItems({ ids: selectedIds, ...payload }));
|
setIsConfirmRemoveModalOpen(false);
|
||||||
setIsConfirmRemoveModalOpen(false);
|
}, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]);
|
||||||
},
|
|
||||||
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||||
shouldBlockRefresh.current = false;
|
shouldBlockRefresh.current = false;
|
||||||
setIsConfirmRemoveModalOpen(false);
|
setIsConfirmRemoveModalOpen(false);
|
||||||
}, [setIsConfirmRemoveModalOpen]);
|
}, [setIsConfirmRemoveModalOpen]);
|
||||||
|
|
||||||
const {
|
|
||||||
handleFirstPagePress,
|
|
||||||
handlePreviousPagePress,
|
|
||||||
handleNextPagePress,
|
|
||||||
handleLastPagePress,
|
|
||||||
handlePageSelect,
|
|
||||||
} = usePaging({
|
|
||||||
page,
|
|
||||||
totalPages,
|
|
||||||
gotoPage: gotoQueuePage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleFilterSelect = useCallback(
|
const handleFilterSelect = useCallback(
|
||||||
(selectedFilterKey: string | number) => {
|
(selectedFilterKey: string | number) => {
|
||||||
dispatch(setQueueFilter({ selectedFilterKey }));
|
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||||
},
|
},
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortPress = useCallback(
|
const handleSortPress = useCallback((sortKey: string) => {
|
||||||
(sortKey: string) => {
|
setQueueOption('sortKey', sortKey);
|
||||||
dispatch(setQueueSort({ sortKey }));
|
}, []);
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTableOptionChange = useCallback(
|
const handleTableOptionChange = useCallback(
|
||||||
(payload: TableOptionsChangePayload) => {
|
(payload: TableOptionsChangePayload) => {
|
||||||
dispatch(setQueueTableOption(payload));
|
setQueueOptions(payload);
|
||||||
|
|
||||||
if (payload.pageSize) {
|
if (payload.pageSize) {
|
||||||
dispatch(gotoQueuePage({ page: 1 }));
|
goToPage(1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[goToPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requestCurrentPage) {
|
const episodeIds = selectUniqueIds(records, 'episodeIds');
|
||||||
dispatch(fetchQueue());
|
|
||||||
} else {
|
|
||||||
dispatch(gotoQueuePage({ page: 1 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
dispatch(clearQueue());
|
|
||||||
};
|
|
||||||
}, [requestCurrentPage, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
|
|
||||||
items,
|
|
||||||
'episodeId'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (episodeIds.length) {
|
if (episodeIds.length) {
|
||||||
dispatch(fetchEpisodes({ episodeIds }));
|
dispatch(fetchEpisodes({ episodeIds }));
|
||||||
} else {
|
} else {
|
||||||
dispatch(clearEpisodes());
|
dispatch(clearEpisodes());
|
||||||
}
|
}
|
||||||
}, [items, dispatch]);
|
}, [records, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const repopulate = () => {
|
const repopulate = () => {
|
||||||
dispatch(fetchQueue());
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
registerPagePopulator(repopulate);
|
registerPagePopulator(repopulate);
|
||||||
@@ -244,7 +214,7 @@ function Queue() {
|
|||||||
return () => {
|
return () => {
|
||||||
unregisterPagePopulator(repopulate);
|
unregisterPagePopulator(repopulate);
|
||||||
};
|
};
|
||||||
}, [dispatch]);
|
}, [refetch]);
|
||||||
|
|
||||||
if (!shouldBlockRefresh.current) {
|
if (!shouldBlockRefresh.current) {
|
||||||
currentQueue.current = (
|
currentQueue.current = (
|
||||||
@@ -255,7 +225,7 @@ function Queue() {
|
|||||||
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
|
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isAllPopulated && !hasError && !items.length ? (
|
{isAllPopulated && !hasError && !records.length ? (
|
||||||
<Alert kind={kinds.INFO}>
|
<Alert kind={kinds.INFO}>
|
||||||
{selectedFilterKey !== 'all' && count > 0
|
{selectedFilterKey !== 'all' && count > 0
|
||||||
? translate('QueueFilterHasNoItems')
|
? translate('QueueFilterHasNoItems')
|
||||||
@@ -263,7 +233,7 @@ function Queue() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isAllPopulated && !hasError && !!items.length ? (
|
{isAllPopulated && !hasError && !!records.length ? (
|
||||||
<div>
|
<div>
|
||||||
<Table
|
<Table
|
||||||
selectAll={true}
|
selectAll={true}
|
||||||
@@ -279,11 +249,10 @@ function Queue() {
|
|||||||
onSortPress={handleSortPress}
|
onSortPress={handleSortPress}
|
||||||
>
|
>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((item) => {
|
{records.map((item) => {
|
||||||
return (
|
return (
|
||||||
<QueueRow
|
<QueueRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
episodeId={item.episodeId}
|
|
||||||
isSelected={selectedState[item.id]}
|
isSelected={selectedState[item.id]}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
{...item}
|
{...item}
|
||||||
@@ -302,11 +271,7 @@ function Queue() {
|
|||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalRecords={totalRecords}
|
totalRecords={totalRecords}
|
||||||
isFetching={isFetching}
|
isFetching={isFetching}
|
||||||
onFirstPagePress={handleFirstPagePress}
|
onPageSelect={goToPage}
|
||||||
onPreviousPagePress={handlePreviousPagePress}
|
|
||||||
onNextPagePress={handleNextPagePress}
|
|
||||||
onLastPagePress={handleLastPagePress}
|
|
||||||
onPageSelect={handlePageSelect}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -377,7 +342,7 @@ function Queue() {
|
|||||||
canChangeCategory={
|
canChangeCategory={
|
||||||
isConfirmRemoveModalOpen &&
|
isConfirmRemoveModalOpen &&
|
||||||
selectedIds.every((id) => {
|
selectedIds.every((id) => {
|
||||||
const item = items.find((i) => i.id === id);
|
const item = records.find((i) => i.id === id);
|
||||||
|
|
||||||
return !!(item && item.downloadClientHasPostImportCategory);
|
return !!(item && item.downloadClientHasPostImportCategory);
|
||||||
})
|
})
|
||||||
@@ -385,7 +350,7 @@ function Queue() {
|
|||||||
canIgnore={
|
canIgnore={
|
||||||
isConfirmRemoveModalOpen &&
|
isConfirmRemoveModalOpen &&
|
||||||
selectedIds.every((id) => {
|
selectedIds.every((id) => {
|
||||||
const item = items.find((i) => i.id === id);
|
const item = records.find((i) => i.id === id);
|
||||||
|
|
||||||
return !!(item && item.seriesId && item.episodeId);
|
return !!(item && item.seriesId && item.episodeId);
|
||||||
})
|
})
|
||||||
@@ -393,7 +358,7 @@ function Queue() {
|
|||||||
isPending={
|
isPending={
|
||||||
isConfirmRemoveModalOpen &&
|
isConfirmRemoveModalOpen &&
|
||||||
selectedIds.every((id) => {
|
selectedIds.every((id) => {
|
||||||
const item = items.find((i) => i.id === id);
|
const item = records.find((i) => i.id === id);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import styles from './QueueDetails.css';
|
|||||||
interface QueueDetailsProps {
|
interface QueueDetailsProps {
|
||||||
title: string;
|
title: string;
|
||||||
size: number;
|
size: number;
|
||||||
sizeleft: number;
|
sizeLeft: number;
|
||||||
estimatedCompletionTime?: string;
|
estimatedCompletionTime?: string;
|
||||||
status: string;
|
status: string;
|
||||||
trackedDownloadState?: QueueTrackedDownloadState;
|
trackedDownloadState?: QueueTrackedDownloadState;
|
||||||
@@ -28,7 +28,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
|||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
size,
|
size,
|
||||||
sizeleft,
|
sizeLeft,
|
||||||
status,
|
status,
|
||||||
trackedDownloadState = 'downloading',
|
trackedDownloadState = 'downloading',
|
||||||
trackedDownloadStatus = 'ok',
|
trackedDownloadStatus = 'ok',
|
||||||
@@ -37,7 +37,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
|||||||
progressBar,
|
progressBar,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const progress = 100 - (sizeleft / size) * 100;
|
const progress = 100 - (sizeLeft / size) * 100;
|
||||||
const isDownloading = status === 'downloading';
|
const isDownloading = status === 'downloading';
|
||||||
const isPaused = status === 'paused';
|
const isPaused = status === 'paused';
|
||||||
const hasWarning = trackedDownloadStatus === 'warning';
|
const hasWarning = trackedDownloadStatus === 'warning';
|
||||||
|
|||||||
@@ -1,50 +1,26 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||||
import { setQueueFilter } from 'Store/Actions/queueActions';
|
import { setQueueOption } from './queueOptionsStore';
|
||||||
|
import useQueue, { FILTER_BUILDER } from './useQueue';
|
||||||
function createQueueSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.queue.paged.items,
|
|
||||||
(queueItems) => {
|
|
||||||
return queueItems;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFilterBuilderPropsSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.queue.paged.filterBuilderProps,
|
|
||||||
(filterBuilderProps) => {
|
|
||||||
return filterBuilderProps;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type QueueFilterModalProps = FilterModalProps<History>;
|
type QueueFilterModalProps = FilterModalProps<History>;
|
||||||
|
|
||||||
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||||
const sectionItems = useSelector(createQueueSelector());
|
const { records } = useQueue();
|
||||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
|
||||||
const customFilterType = 'queue';
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const dispatchSetFilter = useCallback(
|
const dispatchSetFilter = useCallback(
|
||||||
(payload: unknown) => {
|
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||||
dispatch(setQueueFilter(payload));
|
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||||
},
|
},
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterModal
|
<FilterModal
|
||||||
{...props}
|
{...props}
|
||||||
sectionItems={sectionItems}
|
sectionItems={records}
|
||||||
filterBuilderProps={filterBuilderProps}
|
filterBuilderProps={FILTER_BUILDER}
|
||||||
customFilterType={customFilterType}
|
customFilterType="queue"
|
||||||
dispatchSetFilter={dispatchSetFilter}
|
dispatchSetFilter={dispatchSetFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,33 +1,30 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
import FormLabel from 'Components/Form/FormLabel';
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
|
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
||||||
import { inputTypes } from 'Helpers/Props';
|
import { inputTypes } from 'Helpers/Props';
|
||||||
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
import {
|
||||||
|
QueueOptions as QueueOptionsType,
|
||||||
|
setQueueOption,
|
||||||
|
useQueueOption,
|
||||||
|
} from './queueOptionsStore';
|
||||||
|
import useQueue from './useQueue';
|
||||||
|
|
||||||
function QueueOptions() {
|
function QueueOptions() {
|
||||||
const dispatch = useDispatch();
|
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
||||||
const { includeUnknownSeriesItems } = useSelector(
|
const { goToPage } = useQueue();
|
||||||
(state: AppState) => state.queue.options
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOptionChange = useCallback(
|
const handleOptionChange = useCallback(
|
||||||
({ name, value }: InputChanged<boolean>) => {
|
({ name, value }: OptionChanged<QueueOptionsType>) => {
|
||||||
dispatch(
|
setQueueOption(name, value);
|
||||||
setQueueOption({
|
|
||||||
[name]: value,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (name === 'includeUnknownSeriesItems') {
|
if (name === 'includeUnknownSeriesItems') {
|
||||||
dispatch(gotoQueuePage({ page: 1 }));
|
goToPage(1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[goToPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,6 +36,7 @@ function QueueOptions() {
|
|||||||
name="includeUnknownSeriesItems"
|
name="includeUnknownSeriesItems"
|
||||||
value={includeUnknownSeriesItems}
|
value={includeUnknownSeriesItems}
|
||||||
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
||||||
|
// @ts-expect-error - The typing for inputs needs more work
|
||||||
onChange={handleOptionChange}
|
onChange={handleOptionChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||||
import { Error } from 'App/State/AppSectionState';
|
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||||
import ProgressBar from 'Components/ProgressBar';
|
import ProgressBar from 'Components/ProgressBar';
|
||||||
@@ -15,16 +14,13 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
|||||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
import useEpisodes from 'Episode/useEpisodes';
|
||||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
|
||||||
import useEpisode from 'Episode/useEpisode';
|
|
||||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||||
import Language from 'Language/Language';
|
import Language from 'Language/Language';
|
||||||
import { QualityModel } from 'Quality/Quality';
|
import { QualityModel } from 'Quality/Quality';
|
||||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||||
import useSeries from 'Series/useSeries';
|
import useSeries from 'Series/useSeries';
|
||||||
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
|
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
import CustomFormat from 'typings/CustomFormat';
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
import { SelectStateInputProps } from 'typings/props';
|
||||||
@@ -36,15 +32,18 @@ import {
|
|||||||
import formatBytes from 'Utilities/Number/formatBytes';
|
import formatBytes from 'Utilities/Number/formatBytes';
|
||||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
import EpisodeCellContent from './EpisodeCellContent';
|
||||||
|
import EpisodeTitleCellContent from './EpisodeTitleCellContent';
|
||||||
import QueueStatusCell from './QueueStatusCell';
|
import QueueStatusCell from './QueueStatusCell';
|
||||||
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
||||||
import TimeleftCell from './TimeleftCell';
|
import TimeLeftCell from './TimeLeftCell';
|
||||||
|
import { useGrabQueueItem, useRemoveQueueItem } from './useQueue';
|
||||||
import styles from './QueueRow.css';
|
import styles from './QueueRow.css';
|
||||||
|
|
||||||
interface QueueRowProps {
|
interface QueueRowProps {
|
||||||
id: number;
|
id: number;
|
||||||
seriesId?: number;
|
seriesId?: number;
|
||||||
episodeId?: number;
|
episodeIds: number[];
|
||||||
downloadId?: string;
|
downloadId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -58,16 +57,16 @@ interface QueueRowProps {
|
|||||||
customFormatScore: number;
|
customFormatScore: number;
|
||||||
protocol: DownloadProtocol;
|
protocol: DownloadProtocol;
|
||||||
indexer?: string;
|
indexer?: string;
|
||||||
|
isFullSeason: boolean;
|
||||||
|
seasonNumbers: number[];
|
||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
downloadClient?: string;
|
downloadClient?: string;
|
||||||
downloadClientHasPostImportCategory?: boolean;
|
downloadClientHasPostImportCategory?: boolean;
|
||||||
estimatedCompletionTime?: string;
|
estimatedCompletionTime?: string;
|
||||||
added?: string;
|
added?: string;
|
||||||
timeleft?: string;
|
timeLeft?: string;
|
||||||
size: number;
|
size: number;
|
||||||
sizeleft: number;
|
sizeLeft: number;
|
||||||
isGrabbing?: boolean;
|
|
||||||
grabError?: Error;
|
|
||||||
isRemoving?: boolean;
|
isRemoving?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
columns: Column[];
|
columns: Column[];
|
||||||
@@ -79,7 +78,7 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
seriesId,
|
seriesId,
|
||||||
episodeId,
|
episodeIds,
|
||||||
downloadId,
|
downloadId,
|
||||||
title,
|
title,
|
||||||
status,
|
status,
|
||||||
@@ -97,25 +96,25 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
downloadClient,
|
downloadClient,
|
||||||
downloadClientHasPostImportCategory,
|
downloadClientHasPostImportCategory,
|
||||||
estimatedCompletionTime,
|
estimatedCompletionTime,
|
||||||
|
isFullSeason,
|
||||||
|
seasonNumbers,
|
||||||
added,
|
added,
|
||||||
timeleft,
|
timeLeft,
|
||||||
size,
|
size,
|
||||||
sizeleft,
|
sizeLeft,
|
||||||
isGrabbing = false,
|
|
||||||
grabError,
|
|
||||||
isRemoving = false,
|
|
||||||
isSelected,
|
isSelected,
|
||||||
columns,
|
columns,
|
||||||
onSelectedChange,
|
onSelectedChange,
|
||||||
onQueueRowModalOpenOrClose,
|
onQueueRowModalOpenOrClose,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const series = useSeries(seriesId);
|
const series = useSeries(seriesId);
|
||||||
const episode = useEpisode(episodeId, 'episodes');
|
const episodes = useEpisodes(episodeIds, 'episodes');
|
||||||
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
||||||
createUISettingsSelector()
|
createUISettingsSelector()
|
||||||
);
|
);
|
||||||
|
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
|
||||||
|
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
|
||||||
|
|
||||||
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
|
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -124,8 +123,8 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const handleGrabPress = useCallback(() => {
|
const handleGrabPress = useCallback(() => {
|
||||||
dispatch(grabQueueItem({ id }));
|
grabQueueItem();
|
||||||
}, [id, dispatch]);
|
}, [grabQueueItem]);
|
||||||
|
|
||||||
const handleInteractiveImportPress = useCallback(() => {
|
const handleInteractiveImportPress = useCallback(() => {
|
||||||
onQueueRowModalOpenOrClose(true);
|
onQueueRowModalOpenOrClose(true);
|
||||||
@@ -142,21 +141,22 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
setIsRemoveQueueItemModalOpen(true);
|
setIsRemoveQueueItemModalOpen(true);
|
||||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||||
|
|
||||||
const handleRemoveQueueItemModalConfirmed = useCallback(
|
const handleRemoveQueueItemModalConfirmed = useCallback(() => {
|
||||||
(payload: RemovePressProps) => {
|
onQueueRowModalOpenOrClose(false);
|
||||||
onQueueRowModalOpenOrClose(false);
|
removeQueueItem();
|
||||||
dispatch(removeQueueItem({ id, ...payload }));
|
setIsRemoveQueueItemModalOpen(false);
|
||||||
setIsRemoveQueueItemModalOpen(false);
|
}, [
|
||||||
},
|
setIsRemoveQueueItemModalOpen,
|
||||||
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch]
|
removeQueueItem,
|
||||||
);
|
onQueueRowModalOpenOrClose,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleRemoveQueueItemModalClose = useCallback(() => {
|
const handleRemoveQueueItemModalClose = useCallback(() => {
|
||||||
onQueueRowModalOpenOrClose(false);
|
onQueueRowModalOpenOrClose(false);
|
||||||
setIsRemoveQueueItemModalOpen(false);
|
setIsRemoveQueueItemModalOpen(false);
|
||||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||||
|
|
||||||
const progress = 100 - (sizeleft / size) * 100;
|
const progress = 100 - (sizeLeft / size) * 100;
|
||||||
const showInteractiveImport =
|
const showInteractiveImport =
|
||||||
status === 'completed' && trackedDownloadStatus === 'warning';
|
status === 'completed' && trackedDownloadStatus === 'warning';
|
||||||
const isPending =
|
const isPending =
|
||||||
@@ -209,23 +209,12 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
if (name === 'episode') {
|
if (name === 'episode') {
|
||||||
return (
|
return (
|
||||||
<TableRowCell key={name}>
|
<TableRowCell key={name}>
|
||||||
{episode ? (
|
<EpisodeCellContent
|
||||||
<SeasonEpisodeNumber
|
episodes={episodes}
|
||||||
seasonNumber={episode.seasonNumber}
|
isFullSeason={isFullSeason}
|
||||||
episodeNumber={episode.episodeNumber}
|
seasonNumber={seasonNumbers[0]}
|
||||||
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
series={series}
|
||||||
seriesType={series?.seriesType}
|
/>
|
||||||
alternateTitles={series?.alternateTitles}
|
|
||||||
sceneSeasonNumber={episode.sceneSeasonNumber}
|
|
||||||
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
|
||||||
sceneAbsoluteEpisodeNumber={
|
|
||||||
episode.sceneAbsoluteEpisodeNumber
|
|
||||||
}
|
|
||||||
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</TableRowCell>
|
</TableRowCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -233,27 +222,37 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
if (name === 'episodes.title') {
|
if (name === 'episodes.title') {
|
||||||
return (
|
return (
|
||||||
<TableRowCell key={name}>
|
<TableRowCell key={name}>
|
||||||
{series && episode ? (
|
<EpisodeTitleCellContent episodes={episodes} series={series} />
|
||||||
<EpisodeTitleLink
|
|
||||||
episodeId={episode.id}
|
|
||||||
seriesId={series.id}
|
|
||||||
episodeTitle={episode.title}
|
|
||||||
episodeEntity="episodes"
|
|
||||||
showOpenSeriesButton={true}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</TableRowCell>
|
</TableRowCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'episodes.airDateUtc') {
|
if (name === 'episodes.airDateUtc') {
|
||||||
if (episode) {
|
if (episodes.length === 0) {
|
||||||
return <RelativeDateCell key={name} date={episode.airDateUtc} />;
|
return <TableRowCell key={name}>-</TableRowCell>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <TableRowCell key={name}>-</TableRowCell>;
|
if (episodes.length === 1) {
|
||||||
|
return (
|
||||||
|
<RelativeDateCell key={name} date={episodes[0].airDateUtc} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRowCell key={name}>
|
||||||
|
<RelativeDateCell
|
||||||
|
key={name}
|
||||||
|
component="span"
|
||||||
|
date={episodes[0].airDateUtc}
|
||||||
|
/>
|
||||||
|
{' - '}
|
||||||
|
<RelativeDateCell
|
||||||
|
key={name}
|
||||||
|
component="span"
|
||||||
|
date={episodes[episodes.length - 1].airDateUtc}
|
||||||
|
/>
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'languages') {
|
if (name === 'languages') {
|
||||||
@@ -325,13 +324,13 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
|
|
||||||
if (name === 'estimatedCompletionTime') {
|
if (name === 'estimatedCompletionTime') {
|
||||||
return (
|
return (
|
||||||
<TimeleftCell
|
<TimeLeftCell
|
||||||
key={name}
|
key={name}
|
||||||
status={status}
|
status={status}
|
||||||
estimatedCompletionTime={estimatedCompletionTime}
|
estimatedCompletionTime={estimatedCompletionTime}
|
||||||
timeleft={timeleft}
|
timeLeft={timeLeft}
|
||||||
size={size}
|
size={size}
|
||||||
sizeleft={sizeleft}
|
sizeLeft={sizeLeft}
|
||||||
showRelativeDates={showRelativeDates}
|
showRelativeDates={showRelativeDates}
|
||||||
shortDateFormat={shortDateFormat}
|
shortDateFormat={shortDateFormat}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function QueueStatus(props: QueueStatusProps) {
|
|||||||
|
|
||||||
if (trackedDownloadState === 'importing') {
|
if (trackedDownloadState === 'importing') {
|
||||||
title += ` - ${translate('Importing')}`;
|
title += ` - ${translate('Importing')}`;
|
||||||
iconKind = kinds.PURPLE;
|
iconKind = kinds.PRIMARY;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trackedDownloadState === 'failedPending') {
|
if (trackedDownloadState === 'failedPending') {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
import FormLabel from 'Components/Form/FormLabel';
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
@@ -11,19 +9,16 @@ import ModalBody from 'Components/Modal/ModalBody';
|
|||||||
import ModalContent from 'Components/Modal/ModalContent';
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
||||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||||
import { setQueueRemovalOption } from 'Store/Actions/queueActions';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
import {
|
||||||
|
QueueOptions,
|
||||||
|
setQueueOption,
|
||||||
|
useQueueOption,
|
||||||
|
} from './queueOptionsStore';
|
||||||
import styles from './RemoveQueueItemModal.css';
|
import styles from './RemoveQueueItemModal.css';
|
||||||
|
|
||||||
export interface RemovePressProps {
|
|
||||||
remove: boolean;
|
|
||||||
changeCategory: boolean;
|
|
||||||
blocklist: boolean;
|
|
||||||
skipRedownload: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RemoveQueueItemModalProps {
|
interface RemoveQueueItemModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
sourceTitle?: string;
|
sourceTitle?: string;
|
||||||
@@ -31,7 +26,7 @@ interface RemoveQueueItemModalProps {
|
|||||||
canIgnore: boolean;
|
canIgnore: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
selectedCount?: number;
|
selectedCount?: number;
|
||||||
onRemovePress(props: RemovePressProps): void;
|
onRemovePress(): void;
|
||||||
onModalClose: () => void;
|
onModalClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,13 +42,8 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
|||||||
onModalClose,
|
onModalClose,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const multipleSelected = selectedCount && selectedCount > 1;
|
const multipleSelected = selectedCount && selectedCount > 1;
|
||||||
|
const { removalMethod, blocklistMethod } = useQueueOption('removalOptions');
|
||||||
const { removalMethod, blocklistMethod } = useSelector(
|
|
||||||
(state: AppState) => state.queue.removalOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
const { title, message } = useMemo(() => {
|
const { title, message } = useMemo(() => {
|
||||||
if (!selectedCount) {
|
if (!selectedCount) {
|
||||||
@@ -138,20 +128,19 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
|||||||
}, [isPending, multipleSelected]);
|
}, [isPending, multipleSelected]);
|
||||||
|
|
||||||
const handleRemovalOptionInputChange = useCallback(
|
const handleRemovalOptionInputChange = useCallback(
|
||||||
({ name, value }: InputChanged) => {
|
({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => {
|
||||||
dispatch(setQueueRemovalOption({ [name]: value }));
|
setQueueOption('removalOptions', {
|
||||||
|
removalMethod,
|
||||||
|
blocklistMethod,
|
||||||
|
[name]: value,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[dispatch]
|
[removalMethod, blocklistMethod]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConfirmRemove = useCallback(() => {
|
const handleConfirmRemove = useCallback(() => {
|
||||||
onRemovePress({
|
onRemovePress();
|
||||||
remove: removalMethod === 'removeFromClient',
|
}, [onRemovePress]);
|
||||||
changeCategory: removalMethod === 'changeCategory',
|
|
||||||
blocklist: blocklistMethod !== 'doNotBlocklist',
|
|
||||||
skipRedownload: blocklistMethod === 'blocklistOnly',
|
|
||||||
});
|
|
||||||
}, [removalMethod, blocklistMethod, onRemovePress]);
|
|
||||||
|
|
||||||
const handleModalClose = useCallback(() => {
|
const handleModalClose = useCallback(() => {
|
||||||
onModalClose();
|
onModalClose();
|
||||||
@@ -178,6 +167,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
|||||||
helpTextWarning={translate(
|
helpTextWarning={translate(
|
||||||
'RemoveQueueItemRemovalMethodHelpTextWarning'
|
'RemoveQueueItemRemovalMethodHelpTextWarning'
|
||||||
)}
|
)}
|
||||||
|
// @ts-expect-error - The typing for inputs needs more work
|
||||||
onChange={handleRemovalOptionInputChange}
|
onChange={handleRemovalOptionInputChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
@@ -196,6 +186,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
|||||||
value={blocklistMethod}
|
value={blocklistMethod}
|
||||||
values={blocklistMethodOptions}
|
values={blocklistMethodOptions}
|
||||||
helpText={translate('BlocklistReleaseHelpText')}
|
helpText={translate('BlocklistReleaseHelpText')}
|
||||||
|
// @ts-expect-error - The typing for inputs needs more work
|
||||||
onChange={handleRemovalOptionInputChange}
|
onChange={handleRemovalOptionInputChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|||||||
@@ -1,33 +1,9 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
|
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
|
||||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
import useQueueStatus from './useQueueStatus';
|
||||||
import { fetchQueueStatus } from 'Store/Actions/queueActions';
|
|
||||||
import createQueueStatusSelector from './createQueueStatusSelector';
|
|
||||||
|
|
||||||
function QueueStatus() {
|
function QueueStatus() {
|
||||||
const dispatch = useDispatch();
|
const { errors, warnings, count } = useQueueStatus();
|
||||||
const { isConnected, isReconnecting } = useSelector(
|
|
||||||
(state: AppState) => state.app
|
|
||||||
);
|
|
||||||
const { isPopulated, count, errors, warnings } = useSelector(
|
|
||||||
createQueueStatusSelector()
|
|
||||||
);
|
|
||||||
|
|
||||||
const wasReconnecting = usePrevious(isReconnecting);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPopulated) {
|
|
||||||
dispatch(fetchQueueStatus());
|
|
||||||
}
|
|
||||||
}, [isPopulated, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isConnected && wasReconnecting) {
|
|
||||||
dispatch(fetchQueueStatus());
|
|
||||||
}
|
|
||||||
}, [isConnected, wasReconnecting, dispatch]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
|
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { createSelector } from 'reselect';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
|
|
||||||
function createQueueStatusSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.queue.status.isPopulated,
|
|
||||||
(state: AppState) => state.queue.status.item,
|
|
||||||
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
|
|
||||||
(isPopulated, status, includeUnknownSeriesItems) => {
|
|
||||||
const {
|
|
||||||
errors,
|
|
||||||
warnings,
|
|
||||||
unknownErrors,
|
|
||||||
unknownWarnings,
|
|
||||||
count,
|
|
||||||
totalCount,
|
|
||||||
} = status;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...status,
|
|
||||||
isPopulated,
|
|
||||||
count: includeUnknownSeriesItems ? totalCount : count,
|
|
||||||
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
|
|
||||||
warnings: includeUnknownSeriesItems
|
|
||||||
? warnings || unknownWarnings
|
|
||||||
: warnings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createQueueStatusSelector;
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
|
import { useQueueOption } from '../queueOptionsStore';
|
||||||
|
|
||||||
|
export interface QueueStatus {
|
||||||
|
totalCount: number;
|
||||||
|
count: number;
|
||||||
|
unknownCount: number;
|
||||||
|
errors: boolean;
|
||||||
|
warnings: boolean;
|
||||||
|
unknownErrors: boolean;
|
||||||
|
unknownWarnings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useQueueStatus() {
|
||||||
|
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
||||||
|
|
||||||
|
const { data } = useApiQuery<QueueStatus>({
|
||||||
|
path: '/queue/status',
|
||||||
|
queryParams: {
|
||||||
|
includeUnknownSeriesItems,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return {
|
||||||
|
count: 0,
|
||||||
|
errors: false,
|
||||||
|
warnings: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
unknownErrors,
|
||||||
|
unknownWarnings,
|
||||||
|
count,
|
||||||
|
totalCount,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (includeUnknownSeriesItems) {
|
||||||
|
return {
|
||||||
|
count: totalCount,
|
||||||
|
errors: errors || unknownErrors,
|
||||||
|
warnings: warnings || unknownWarnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
.timeleft {
|
.timeLeft {
|
||||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||||
|
|
||||||
width: 100px;
|
width: 100px;
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// This file is automatically generated.
|
// This file is automatically generated.
|
||||||
// Please do not change this file!
|
// Please do not change this file!
|
||||||
interface CssExports {
|
interface CssExports {
|
||||||
'timeleft': string;
|
'timeLeft': string;
|
||||||
}
|
}
|
||||||
export const cssExports: CssExports;
|
export const cssExports: CssExports;
|
||||||
export default cssExports;
|
export default cssExports;
|
||||||
+15
-15
@@ -8,26 +8,26 @@ import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
|||||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||||
import formatBytes from 'Utilities/Number/formatBytes';
|
import formatBytes from 'Utilities/Number/formatBytes';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './TimeleftCell.css';
|
import styles from './TimeLeftCell.css';
|
||||||
|
|
||||||
interface TimeleftCellProps {
|
interface TimeLeftCellProps {
|
||||||
estimatedCompletionTime?: string;
|
estimatedCompletionTime?: string;
|
||||||
timeleft?: string;
|
timeLeft?: string;
|
||||||
status: string;
|
status: string;
|
||||||
size: number;
|
size: number;
|
||||||
sizeleft: number;
|
sizeLeft: number;
|
||||||
showRelativeDates: boolean;
|
showRelativeDates: boolean;
|
||||||
shortDateFormat: string;
|
shortDateFormat: string;
|
||||||
timeFormat: string;
|
timeFormat: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimeleftCell(props: TimeleftCellProps) {
|
function TimeLeftCell(props: TimeLeftCellProps) {
|
||||||
const {
|
const {
|
||||||
estimatedCompletionTime,
|
estimatedCompletionTime,
|
||||||
timeleft,
|
timeLeft,
|
||||||
status,
|
status,
|
||||||
size,
|
size,
|
||||||
sizeleft,
|
sizeLeft,
|
||||||
showRelativeDates,
|
showRelativeDates,
|
||||||
shortDateFormat,
|
shortDateFormat,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
@@ -44,7 +44,7 @@ function TimeleftCell(props: TimeleftCellProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRowCell className={styles.timeleft}>
|
<TableRowCell className={styles.timeLeft}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
anchor={<Icon name={icons.INFO} />}
|
anchor={<Icon name={icons.INFO} />}
|
||||||
tooltip={translate('DelayingDownloadUntil', { date, time })}
|
tooltip={translate('DelayingDownloadUntil', { date, time })}
|
||||||
@@ -66,7 +66,7 @@ function TimeleftCell(props: TimeleftCellProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRowCell className={styles.timeleft}>
|
<TableRowCell className={styles.timeLeft}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
anchor={<Icon name={icons.INFO} />}
|
anchor={<Icon name={icons.INFO} />}
|
||||||
tooltip={translate('RetryingDownloadOn', { date, time })}
|
tooltip={translate('RetryingDownloadOn', { date, time })}
|
||||||
@@ -77,21 +77,21 @@ function TimeleftCell(props: TimeleftCellProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!timeleft || status === 'completed' || status === 'failed') {
|
if (!timeLeft || status === 'completed' || status === 'failed') {
|
||||||
return <TableRowCell className={styles.timeleft}>-</TableRowCell>;
|
return <TableRowCell className={styles.timeLeft}>-</TableRowCell>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalSize = formatBytes(size);
|
const totalSize = formatBytes(size);
|
||||||
const remainingSize = formatBytes(sizeleft);
|
const remainingSize = formatBytes(sizeLeft);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRowCell
|
<TableRowCell
|
||||||
className={styles.timeleft}
|
className={styles.timeLeft}
|
||||||
title={`${remainingSize} / ${totalSize}`}
|
title={`${remainingSize} / ${totalSize}`}
|
||||||
>
|
>
|
||||||
{formatTimeSpan(timeleft)}
|
{formatTimeSpan(timeLeft)}
|
||||||
</TableRowCell>
|
</TableRowCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TimeleftCell;
|
export default TimeLeftCell;
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import {
|
||||||
|
createOptionsStore,
|
||||||
|
PageableOptions,
|
||||||
|
} from 'Helpers/Hooks/useOptionsStore';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
|
interface QueueRemovalOptions {
|
||||||
|
removalMethod: 'changeCategory' | 'ignore' | 'removeFromClient';
|
||||||
|
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueOptions extends PageableOptions {
|
||||||
|
includeUnknownSeriesItems: boolean;
|
||||||
|
removalOptions: QueueRemovalOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { useOptions, useOption, setOptions, setOption } =
|
||||||
|
createOptionsStore<QueueOptions>('queue_options', () => {
|
||||||
|
return {
|
||||||
|
includeUnknownSeriesItems: true,
|
||||||
|
pageSize: 20,
|
||||||
|
selectedFilterKey: 'all',
|
||||||
|
sortKey: 'time',
|
||||||
|
sortDirection: 'descending',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
label: '',
|
||||||
|
columnLabel: () => translate('Status'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
isModifiable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'series.sortTitle',
|
||||||
|
label: () => translate('Series'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'episode',
|
||||||
|
label: () => translate('EpisodeMaybePlural'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'episodes.title',
|
||||||
|
label: () => translate('EpisodeTitleMaybePlural'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'episodes.airDateUtc',
|
||||||
|
label: () => translate('EpisodeAirDate'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'languages',
|
||||||
|
label: () => translate('Languages'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'quality',
|
||||||
|
label: () => translate('Quality'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'customFormats',
|
||||||
|
label: () => translate('Formats'),
|
||||||
|
isSortable: false,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'customFormatScore',
|
||||||
|
columnLabel: () => translate('CustomFormatScore'),
|
||||||
|
label: React.createElement(Icon, {
|
||||||
|
name: icons.SCORE,
|
||||||
|
title: () => translate('CustomFormatScore'),
|
||||||
|
}),
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'protocol',
|
||||||
|
label: () => translate('Protocol'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'indexer',
|
||||||
|
label: () => translate('Indexer'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'downloadClient',
|
||||||
|
label: () => translate('DownloadClient'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
label: () => translate('ReleaseTitle'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'size',
|
||||||
|
label: () => translate('Size'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'outputPath',
|
||||||
|
label: () => translate('OutputPath'),
|
||||||
|
isSortable: false,
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'estimatedCompletionTime',
|
||||||
|
label: () => translate('TimeLeft'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'added',
|
||||||
|
label: () => translate('Added'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'progress',
|
||||||
|
label: () => translate('Progress'),
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'actions',
|
||||||
|
label: '',
|
||||||
|
columnLabel: () => translate('Actions'),
|
||||||
|
isVisible: true,
|
||||||
|
isModifiable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removalOptions: {
|
||||||
|
removalMethod: 'removeFromClient',
|
||||||
|
blocklistMethod: 'doNotBlocklist',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useQueueOptions = useOptions;
|
||||||
|
export const setQueueOptions = setOptions;
|
||||||
|
export const useQueueOption = useOption;
|
||||||
|
export const setQueueOption = setOption;
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
||||||
|
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||||
|
import usePage from 'Helpers/Hooks/usePage';
|
||||||
|
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||||
|
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||||
|
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||||
|
import Queue from 'typings/Queue';
|
||||||
|
import getQueryString from 'Utilities/Fetch/getQueryString';
|
||||||
|
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import { useQueueOptions } from './queueOptionsStore';
|
||||||
|
|
||||||
|
interface BulkQueueData {
|
||||||
|
ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FILTERS: Filter[] = [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: () => translate('All'),
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
|
||||||
|
{
|
||||||
|
name: 'seriesIds',
|
||||||
|
label: () => translate('Series'),
|
||||||
|
type: 'equal',
|
||||||
|
valueType: filterBuilderValueTypes.SERIES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'quality',
|
||||||
|
label: () => translate('Quality'),
|
||||||
|
type: 'equal',
|
||||||
|
valueType: filterBuilderValueTypes.QUALITY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'languages',
|
||||||
|
label: () => translate('Languages'),
|
||||||
|
type: 'contains',
|
||||||
|
valueType: filterBuilderValueTypes.LANGUAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'protocol',
|
||||||
|
label: () => translate('Protocol'),
|
||||||
|
type: 'equal',
|
||||||
|
valueType: filterBuilderValueTypes.PROTOCOL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
label: () => translate('Status'),
|
||||||
|
type: 'equal',
|
||||||
|
valueType: filterBuilderValueTypes.QUEUE_STATUS,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useQueue = () => {
|
||||||
|
const { page, goToPage } = usePage('queue');
|
||||||
|
const {
|
||||||
|
includeUnknownSeriesItems,
|
||||||
|
pageSize,
|
||||||
|
selectedFilterKey,
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
} = useQueueOptions();
|
||||||
|
const customFilters = useSelector(
|
||||||
|
createCustomFiltersSelector('queue')
|
||||||
|
) as CustomFilter[];
|
||||||
|
|
||||||
|
const filters = useMemo(() => {
|
||||||
|
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||||
|
}, [selectedFilterKey, customFilters]);
|
||||||
|
|
||||||
|
const { refetch, ...query } = usePagedApiQuery<Queue>({
|
||||||
|
path: '/queue',
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
filters,
|
||||||
|
queryParams: {
|
||||||
|
includeUnknownSeriesItems,
|
||||||
|
},
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
queryOptions: {
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
goToPage,
|
||||||
|
page,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useQueue;
|
||||||
|
|
||||||
|
export const useFilters = () => {
|
||||||
|
return FILTERS;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useRemovalOptions = () => {
|
||||||
|
const { removalOptions } = useQueueOptions();
|
||||||
|
|
||||||
|
return {
|
||||||
|
remove: removalOptions.removalMethod === 'removeFromClient',
|
||||||
|
changeCategory: removalOptions.removalMethod === 'changeCategory',
|
||||||
|
blocklist: removalOptions.blocklistMethod !== 'doNotBlocklist',
|
||||||
|
skipRedownload: removalOptions.blocklistMethod === 'blocklistOnly',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoveQueueItem = (id: number) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const removalOptions = useRemovalOptions();
|
||||||
|
|
||||||
|
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||||
|
path: `/queue/${id}${getQueryString(removalOptions)}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
mutationOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
removeQueueItem: mutate,
|
||||||
|
isRemoving: isPending,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoveQueueItems = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const removalOptions = useRemovalOptions();
|
||||||
|
|
||||||
|
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
|
||||||
|
path: `/queue/bulk${getQueryString(removalOptions)}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
mutationOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
removeQueueItems: mutate,
|
||||||
|
isRemoving: isPending,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGrabQueueItem = (id: number) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [grabError, setGrabError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||||
|
path: `/queue/grab/${id}`,
|
||||||
|
method: 'POST',
|
||||||
|
mutationOptions: {
|
||||||
|
onMutate: () => {
|
||||||
|
setGrabError(null);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setGrabError('Error grabbing queue item');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
grabQueueItem: mutate,
|
||||||
|
isGrabbing: isPending,
|
||||||
|
grabError,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGrabQueueItems = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Explicitly define the types for the mutation so we can pass in no arguments to mutate as expected.
|
||||||
|
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
|
||||||
|
path: '/queue/grab/bulk',
|
||||||
|
method: 'POST',
|
||||||
|
mutationOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
grabQueueItems: mutate,
|
||||||
|
isGrabbing: isPending,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -47,11 +47,7 @@ function AddNewSeriesModalContent({
|
|||||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||||
const isWindows = useIsWindows();
|
const isWindows = useIsWindows();
|
||||||
|
|
||||||
const {
|
const { isAdding, addError, addSeries } = useAddSeries();
|
||||||
isPending: isAdding,
|
|
||||||
error: addError,
|
|
||||||
mutate: addSeries,
|
|
||||||
} = useAddSeries();
|
|
||||||
|
|
||||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||||
return selectSettings(options, {}, addError);
|
return selectSettings(options, {}, addError);
|
||||||
|
|||||||
@@ -33,11 +33,19 @@ export const useAddSeries = () => {
|
|||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
return useApiMutation<Series, AddSeriesPayload>({
|
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
|
||||||
path: '/series',
|
{
|
||||||
method: 'POST',
|
path: '/series',
|
||||||
mutationOptions: {
|
method: 'POST',
|
||||||
onSuccess: onAddSuccess,
|
mutationOptions: {
|
||||||
},
|
onSuccess: onAddSuccess,
|
||||||
});
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdding: isPending,
|
||||||
|
addError: error,
|
||||||
|
addSeries: mutate,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createPersist } from 'Helpers/createPersist';
|
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||||
|
|
||||||
export interface AddSeriesOptions {
|
export interface AddSeriesOptions {
|
||||||
@@ -12,9 +12,8 @@ export interface AddSeriesOptions {
|
|||||||
tags: number[];
|
tags: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
|
const { useOptions, useOption, setOption } =
|
||||||
'add_series_options',
|
createOptionsStore<AddSeriesOptions>('add_series_options', () => {
|
||||||
() => {
|
|
||||||
return {
|
return {
|
||||||
rootFolderPath: '',
|
rootFolderPath: '',
|
||||||
monitor: 'all',
|
monitor: 'all',
|
||||||
@@ -25,25 +24,8 @@ const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
|
|||||||
searchForCutoffUnmetEpisodes: false,
|
searchForCutoffUnmetEpisodes: false,
|
||||||
tags: [],
|
tags: [],
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export const useAddSeriesOptions = () => {
|
export const useAddSeriesOptions = useOptions;
|
||||||
return addSeriesOptionsStore((state) => state);
|
export const useAddSeriesOption = useOption;
|
||||||
};
|
export const setAddSeriesOption = setOption;
|
||||||
|
|
||||||
export const useAddSeriesOption = <K extends keyof AddSeriesOptions>(
|
|
||||||
key: K
|
|
||||||
) => {
|
|
||||||
return addSeriesOptionsStore((state) => state[key]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setAddSeriesOption = <K extends keyof AddSeriesOptions>(
|
|
||||||
key: K,
|
|
||||||
value: AddSeriesOptions[K]
|
|
||||||
) => {
|
|
||||||
addSeriesOptionsStore.setState((state) => ({
|
|
||||||
...state,
|
|
||||||
[key]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import Button from 'Components/Link/Button';
|
import Button from 'Components/Link/Button';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||||
@@ -9,7 +9,6 @@ import ModalFooter from 'Components/Modal/ModalFooter';
|
|||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import { fetchUpdates } from 'Store/Actions/systemActions';
|
|
||||||
import UpdateChanges from 'System/Updates/UpdateChanges';
|
import UpdateChanges from 'System/Updates/UpdateChanges';
|
||||||
import useUpdates from 'System/Updates/useUpdates';
|
import useUpdates from 'System/Updates/useUpdates';
|
||||||
import Update from 'typings/Update';
|
import Update from 'typings/Update';
|
||||||
@@ -64,9 +63,8 @@ interface AppUpdatedModalContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||||
const dispatch = useDispatch();
|
|
||||||
const { version, prevVersion } = useSelector((state: AppState) => state.app);
|
const { version, prevVersion } = useSelector((state: AppState) => state.app);
|
||||||
const { isFetched, error, data } = useUpdates();
|
const { isFetched, error, data, refetch } = useUpdates();
|
||||||
const previousVersion = usePrevious(version);
|
const previousVersion = usePrevious(version);
|
||||||
|
|
||||||
const { onModalClose } = props;
|
const { onModalClose } = props;
|
||||||
@@ -77,15 +75,11 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
|||||||
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
|
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchUpdates());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (version !== previousVersion) {
|
if (version !== previousVersion) {
|
||||||
dispatch(fetchUpdates());
|
refetch();
|
||||||
}
|
}
|
||||||
}, [version, previousVersion, dispatch]);
|
}, [version, previousVersion, refetch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent onModalClose={onModalClose}>
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import OrganizePreviewAppState from './OrganizePreviewAppState';
|
|||||||
import ParseAppState from './ParseAppState';
|
import ParseAppState from './ParseAppState';
|
||||||
import PathsAppState from './PathsAppState';
|
import PathsAppState from './PathsAppState';
|
||||||
import ProviderOptionsAppState from './ProviderOptionsAppState';
|
import ProviderOptionsAppState from './ProviderOptionsAppState';
|
||||||
import QueueAppState from './QueueAppState';
|
|
||||||
import ReleasesAppState from './ReleasesAppState';
|
import ReleasesAppState from './ReleasesAppState';
|
||||||
import RootFolderAppState from './RootFolderAppState';
|
import RootFolderAppState from './RootFolderAppState';
|
||||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
||||||
@@ -91,7 +90,6 @@ interface AppState {
|
|||||||
episodeHistory: HistoryAppState;
|
episodeHistory: HistoryAppState;
|
||||||
episodes: EpisodesAppState;
|
episodes: EpisodesAppState;
|
||||||
episodesSelection: EpisodesAppState;
|
episodesSelection: EpisodesAppState;
|
||||||
history: HistoryAppState;
|
|
||||||
importSeries: ImportSeriesAppState;
|
importSeries: ImportSeriesAppState;
|
||||||
interactiveImport: InteractiveImportAppState;
|
interactiveImport: InteractiveImportAppState;
|
||||||
oAuth: OAuthAppState;
|
oAuth: OAuthAppState;
|
||||||
@@ -99,7 +97,6 @@ interface AppState {
|
|||||||
parse: ParseAppState;
|
parse: ParseAppState;
|
||||||
paths: PathsAppState;
|
paths: PathsAppState;
|
||||||
providerOptions: ProviderOptionsAppState;
|
providerOptions: ProviderOptionsAppState;
|
||||||
queue: QueueAppState;
|
|
||||||
releases: ReleasesAppState;
|
releases: ReleasesAppState;
|
||||||
rootFolders: RootFolderAppState;
|
rootFolders: RootFolderAppState;
|
||||||
series: SeriesAppState;
|
series: SeriesAppState;
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import Queue from 'typings/Queue';
|
|
||||||
import AppSectionState, {
|
|
||||||
AppSectionFilterState,
|
|
||||||
AppSectionItemState,
|
|
||||||
Error,
|
|
||||||
PagedAppSectionState,
|
|
||||||
TableAppSectionState,
|
|
||||||
} from './AppSectionState';
|
|
||||||
|
|
||||||
export interface QueueStatus {
|
|
||||||
totalCount: number;
|
|
||||||
count: number;
|
|
||||||
unknownCount: number;
|
|
||||||
errors: boolean;
|
|
||||||
warnings: boolean;
|
|
||||||
unknownErrors: boolean;
|
|
||||||
unknownWarnings: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueueDetailsAppState extends AppSectionState<Queue> {
|
|
||||||
params: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueuePagedAppState
|
|
||||||
extends AppSectionState<Queue>,
|
|
||||||
AppSectionFilterState<Queue>,
|
|
||||||
PagedAppSectionState,
|
|
||||||
TableAppSectionState {
|
|
||||||
isGrabbing: boolean;
|
|
||||||
grabError: Error;
|
|
||||||
isRemoving: boolean;
|
|
||||||
removeError: Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
|
|
||||||
export type BlocklistMethod =
|
|
||||||
| 'doNotBlocklist'
|
|
||||||
| 'blocklistAndSearch'
|
|
||||||
| 'blocklistOnly';
|
|
||||||
|
|
||||||
interface RemovalOptions {
|
|
||||||
removalMethod: RemovalMethod;
|
|
||||||
blocklistMethod: BlocklistMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QueueAppState {
|
|
||||||
status: AppSectionItemState<QueueStatus>;
|
|
||||||
details: QueueDetailsAppState;
|
|
||||||
paged: QueuePagedAppState;
|
|
||||||
options: {
|
|
||||||
includeUnknownSeriesItems: boolean;
|
|
||||||
};
|
|
||||||
removalOptions: RemovalOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default QueueAppState;
|
|
||||||
@@ -17,6 +17,7 @@ export interface TagDetail extends ModelBase {
|
|||||||
indexerIds: number[];
|
indexerIds: number[];
|
||||||
notificationIds: number[];
|
notificationIds: number[];
|
||||||
restrictionIds: number[];
|
restrictionIds: number[];
|
||||||
|
excludedReleaseProfileIds: number[];
|
||||||
seriesIds: number[];
|
seriesIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import moment from 'moment';
|
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
||||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||||
@@ -13,8 +13,8 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
|||||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import useSeries from 'Series/useSeries';
|
import useSeries from 'Series/useSeries';
|
||||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
|
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
||||||
import formatTime from 'Utilities/Date/formatTime';
|
import formatTime from 'Utilities/Date/formatTime';
|
||||||
import padNumber from 'Utilities/Number/padNumber';
|
import padNumber from 'Utilities/Number/padNumber';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
@@ -57,10 +57,9 @@ function AgendaEvent(props: AgendaEventProps) {
|
|||||||
|
|
||||||
const series = useSeries(seriesId)!;
|
const series = useSeries(seriesId)!;
|
||||||
const episodeFile = useEpisodeFile(episodeFileId);
|
const episodeFile = useEpisodeFile(episodeFileId);
|
||||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
const queueItem = useQueueItemForEpisode(id);
|
||||||
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
|
const { timeFormat, longDateFormat, enableColorImpairedMode, timeZone } =
|
||||||
createUISettingsSelector()
|
useSelector(createUISettingsSelector());
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showEpisodeInformation,
|
showEpisodeInformation,
|
||||||
@@ -71,8 +70,11 @@ function AgendaEvent(props: AgendaEventProps) {
|
|||||||
|
|
||||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
|
||||||
const startTime = moment(airDateUtc);
|
const startTime = convertToTimezone(airDateUtc, timeZone);
|
||||||
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
const endTime = convertToTimezone(airDateUtc, timeZone).add(
|
||||||
|
series.runtime,
|
||||||
|
'minutes'
|
||||||
|
);
|
||||||
const downloading = !!(queueItem || grabbed);
|
const downloading = !!(queueItem || grabbed);
|
||||||
const isMonitored = series.monitored && monitored;
|
const isMonitored = series.monitored && monitored;
|
||||||
const statusStyle = getStatusStyle(
|
const statusStyle = getStatusStyle(
|
||||||
@@ -110,9 +112,10 @@ function AgendaEvent(props: AgendaEventProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={styles.time}>
|
<div className={styles.time}>
|
||||||
{formatTime(airDateUtc, timeFormat)} -{' '}
|
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '}
|
||||||
{formatTime(endTime.toISOString(), timeFormat, {
|
{formatTime(endTime.toISOString(), timeFormat, {
|
||||||
includeMinuteZero: true,
|
includeMinuteZero: true,
|
||||||
|
timeZone,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ import {
|
|||||||
clearEpisodeFiles,
|
clearEpisodeFiles,
|
||||||
fetchEpisodeFiles,
|
fetchEpisodeFiles,
|
||||||
} from 'Store/Actions/episodeFileActions';
|
} from 'Store/Actions/episodeFileActions';
|
||||||
import {
|
|
||||||
clearQueueDetails,
|
|
||||||
fetchQueueDetails,
|
|
||||||
} from 'Store/Actions/queueActions';
|
|
||||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||||
@@ -74,7 +70,6 @@ function Calendar() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
dispatch(clearCalendar());
|
dispatch(clearCalendar());
|
||||||
dispatch(clearQueueDetails());
|
|
||||||
dispatch(clearEpisodeFiles());
|
dispatch(clearEpisodeFiles());
|
||||||
clearTimeout(updateTimeout.current);
|
clearTimeout(updateTimeout.current);
|
||||||
};
|
};
|
||||||
@@ -90,7 +85,6 @@ function Calendar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const repopulate = () => {
|
const repopulate = () => {
|
||||||
dispatch(fetchQueueDetails({ time, view }));
|
|
||||||
dispatch(fetchCalendar({ time, view }));
|
dispatch(fetchCalendar({ time, view }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,16 +119,11 @@ function Calendar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!previousItems || hasDifferentItems(items, previousItems)) {
|
if (!previousItems || hasDifferentItems(items, previousItems)) {
|
||||||
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
|
|
||||||
const episodeFileIds = selectUniqueIds<Episode, number>(
|
const episodeFileIds = selectUniqueIds<Episode, number>(
|
||||||
items,
|
items,
|
||||||
'episodeFileId'
|
'episodeFileId'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (items.length) {
|
|
||||||
dispatch(fetchQueueDetails({ episodeIds }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (episodeFileIds.length) {
|
if (episodeFileIds.length) {
|
||||||
dispatch(fetchEpisodeFiles({ episodeFileIds }));
|
dispatch(fetchEpisodeFiles({ episodeFileIds }));
|
||||||
}
|
}
|
||||||
@@ -144,18 +133,15 @@ function Calendar() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.calendar}>
|
<div className={styles.calendar}>
|
||||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||||
|
|
||||||
{!isFetching && error ? (
|
{!isFetching && error ? (
|
||||||
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!error && isPopulated && view === 'agenda' ? (
|
{!error && isPopulated && view === 'agenda' ? (
|
||||||
<div className={styles.calendarContent}>
|
<div className={styles.calendarContent}>
|
||||||
<CalendarHeader />
|
<CalendarHeader />
|
||||||
<Agenda />
|
<Agenda />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!error && isPopulated && view !== 'agenda' ? (
|
{!error && isPopulated && view !== 'agenda' ? (
|
||||||
<div className={styles.calendarContent}>
|
<div className={styles.calendarContent}>
|
||||||
<CalendarHeader />
|
<CalendarHeader />
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import moment from 'moment';
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||||
|
import Queue from 'typings/Queue';
|
||||||
|
import { isCommandExecuting } from 'Utilities/Command';
|
||||||
|
import isBefore from 'Utilities/Date/isBefore';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
|
function createIsSearchingSelector() {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.calendar.searchMissingCommandId,
|
||||||
|
createCommandsSelector(),
|
||||||
|
(searchMissingCommandId, commands) => {
|
||||||
|
if (searchMissingCommandId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isCommandExecuting(
|
||||||
|
commands.find((command) => {
|
||||||
|
return command.id === searchMissingCommandId;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMissingEpisodeIdsSelector(queueDetails: Queue[]) {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.calendar.start,
|
||||||
|
(state: AppState) => state.calendar.end,
|
||||||
|
(state: AppState) => state.calendar.items,
|
||||||
|
(start, end, episodes) => {
|
||||||
|
return episodes.reduce<number[]>((acc, episode) => {
|
||||||
|
const airDateUtc = episode.airDateUtc;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!episode.episodeFileId &&
|
||||||
|
moment(airDateUtc).isAfter(start) &&
|
||||||
|
moment(airDateUtc).isBefore(end) &&
|
||||||
|
isBefore(episode.airDateUtc) &&
|
||||||
|
!queueDetails.some(
|
||||||
|
(details) => !!details.episode && details.episode.id === episode.id
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
acc.push(episode.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarMissingEpisodeSearchButton() {
|
||||||
|
const queueDetails = useQueueDetails();
|
||||||
|
const missingEpisodeIds = useSelector(
|
||||||
|
createMissingEpisodeIdsSelector(queueDetails)
|
||||||
|
);
|
||||||
|
const isSearchingForMissing = useSelector(createIsSearchingSelector());
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('SearchForMissing')}
|
||||||
|
iconName={icons.SEARCH}
|
||||||
|
isDisabled={!missingEpisodeIds.length}
|
||||||
|
isSpinning={isSearchingForMissing}
|
||||||
|
onPress={handlePress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import moment from 'moment';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import * as commandNames from 'Commands/commandNames';
|
import * as commandNames from 'Commands/commandNames';
|
||||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||||
@@ -11,24 +10,23 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
|||||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||||
|
import Episode from 'Episode/Episode';
|
||||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||||
import { align, icons } from 'Helpers/Props';
|
import { align, icons } from 'Helpers/Props';
|
||||||
import NoSeries from 'Series/NoSeries';
|
import NoSeries from 'Series/NoSeries';
|
||||||
import {
|
import {
|
||||||
searchMissing,
|
|
||||||
setCalendarDaysCount,
|
setCalendarDaysCount,
|
||||||
setCalendarFilter,
|
setCalendarFilter,
|
||||||
} from 'Store/Actions/calendarActions';
|
} from 'Store/Actions/calendarActions';
|
||||||
import { executeCommand } from 'Store/Actions/commandActions';
|
import { executeCommand } from 'Store/Actions/commandActions';
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
|
||||||
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
|
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
|
||||||
import { isCommandExecuting } from 'Utilities/Command';
|
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||||
import isBefore from 'Utilities/Date/isBefore';
|
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import Calendar from './Calendar';
|
import Calendar from './Calendar';
|
||||||
import CalendarFilterModal from './CalendarFilterModal';
|
import CalendarFilterModal from './CalendarFilterModal';
|
||||||
|
import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton';
|
||||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
||||||
import Legend from './Legend/Legend';
|
import Legend from './Legend/Legend';
|
||||||
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
||||||
@@ -36,60 +34,12 @@ import styles from './CalendarPage.css';
|
|||||||
|
|
||||||
const MINIMUM_DAY_WIDTH = 120;
|
const MINIMUM_DAY_WIDTH = 120;
|
||||||
|
|
||||||
function createMissingEpisodeIdsSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.calendar.start,
|
|
||||||
(state: AppState) => state.calendar.end,
|
|
||||||
(state: AppState) => state.calendar.items,
|
|
||||||
(state: AppState) => state.queue.details.items,
|
|
||||||
(start, end, episodes, queueDetails) => {
|
|
||||||
return episodes.reduce<number[]>((acc, episode) => {
|
|
||||||
const airDateUtc = episode.airDateUtc;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!episode.episodeFileId &&
|
|
||||||
moment(airDateUtc).isAfter(start) &&
|
|
||||||
moment(airDateUtc).isBefore(end) &&
|
|
||||||
isBefore(episode.airDateUtc) &&
|
|
||||||
!queueDetails.some(
|
|
||||||
(details) => !!details.episode && details.episode.id === episode.id
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
acc.push(episode.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createIsSearchingSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.calendar.searchMissingCommandId,
|
|
||||||
createCommandsSelector(),
|
|
||||||
(searchMissingCommandId, commands) => {
|
|
||||||
if (searchMissingCommandId == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isCommandExecuting(
|
|
||||||
commands.find((command) => {
|
|
||||||
return command.id === searchMissingCommandId;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CalendarPage() {
|
function CalendarPage() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const { selectedFilterKey, filters } = useSelector(
|
const { selectedFilterKey, filters, items } = useSelector(
|
||||||
(state: AppState) => state.calendar
|
(state: AppState) => state.calendar
|
||||||
);
|
);
|
||||||
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
|
|
||||||
const isSearchingForMissing = useSelector(createIsSearchingSelector());
|
|
||||||
const isRssSyncExecuting = useSelector(
|
const isRssSyncExecuting = useSelector(
|
||||||
createCommandExecutingSelector(commandNames.RSS_SYNC)
|
createCommandExecutingSelector(commandNames.RSS_SYNC)
|
||||||
);
|
);
|
||||||
@@ -127,10 +77,6 @@ function CalendarPage() {
|
|||||||
);
|
);
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleSearchMissingPress = useCallback(() => {
|
|
||||||
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
|
|
||||||
}, [missingEpisodeIds, dispatch]);
|
|
||||||
|
|
||||||
const handleFilterSelect = useCallback(
|
const handleFilterSelect = useCallback(
|
||||||
(key: string | number) => {
|
(key: string | number) => {
|
||||||
dispatch(setCalendarFilter({ selectedFilterKey: key }));
|
dispatch(setCalendarFilter({ selectedFilterKey: key }));
|
||||||
@@ -138,6 +84,10 @@ function CalendarPage() {
|
|||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const episodeIds = useMemo(() => {
|
||||||
|
return selectUniqueIds<Episode, number>(items, 'id');
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (width === 0) {
|
if (width === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -152,71 +102,67 @@ function CalendarPage() {
|
|||||||
}, [width, dispatch]);
|
}, [width, dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent title={translate('Calendar')}>
|
<QueueDetails episodeIds={episodeIds}>
|
||||||
<PageToolbar>
|
<PageContent title={translate('Calendar')}>
|
||||||
<PageToolbarSection>
|
<PageToolbar>
|
||||||
<PageToolbarButton
|
<PageToolbarSection>
|
||||||
label={translate('ICalLink')}
|
<PageToolbarButton
|
||||||
iconName={icons.CALENDAR}
|
label={translate('ICalLink')}
|
||||||
onPress={handleGetCalendarLinkPress}
|
iconName={icons.CALENDAR}
|
||||||
/>
|
onPress={handleGetCalendarLinkPress}
|
||||||
|
/>
|
||||||
|
|
||||||
<PageToolbarSeparator />
|
<PageToolbarSeparator />
|
||||||
|
|
||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
label={translate('RssSync')}
|
label={translate('RssSync')}
|
||||||
iconName={icons.RSS}
|
iconName={icons.RSS}
|
||||||
isSpinning={isRssSyncExecuting}
|
isSpinning={isRssSyncExecuting}
|
||||||
onPress={handleRssSyncPress}
|
onPress={handleRssSyncPress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PageToolbarButton
|
<CalendarMissingEpisodeSearchButton />
|
||||||
label={translate('SearchForMissing')}
|
</PageToolbarSection>
|
||||||
iconName={icons.SEARCH}
|
|
||||||
isDisabled={!missingEpisodeIds.length}
|
|
||||||
isSpinning={isSearchingForMissing}
|
|
||||||
onPress={handleSearchMissingPress}
|
|
||||||
/>
|
|
||||||
</PageToolbarSection>
|
|
||||||
|
|
||||||
<PageToolbarSection alignContent={align.RIGHT}>
|
<PageToolbarSection alignContent={align.RIGHT}>
|
||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
label={translate('Options')}
|
label={translate('Options')}
|
||||||
iconName={icons.POSTER}
|
iconName={icons.POSTER}
|
||||||
onPress={handleOptionsPress}
|
onPress={handleOptionsPress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterMenu
|
<FilterMenu
|
||||||
alignMenu={align.RIGHT}
|
alignMenu={align.RIGHT}
|
||||||
isDisabled={!hasSeries}
|
isDisabled={!hasSeries}
|
||||||
selectedFilterKey={selectedFilterKey}
|
selectedFilterKey={selectedFilterKey}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
customFilters={customFilters}
|
customFilters={customFilters}
|
||||||
filterModalConnectorComponent={CalendarFilterModal}
|
filterModalConnectorComponent={CalendarFilterModal}
|
||||||
onFilterSelect={handleFilterSelect}
|
onFilterSelect={handleFilterSelect}
|
||||||
/>
|
/>
|
||||||
</PageToolbarSection>
|
</PageToolbarSection>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
<PageContentBody
|
<PageContentBody
|
||||||
ref={pageContentRef}
|
ref={pageContentRef}
|
||||||
className={styles.calendarPageBody}
|
className={styles.calendarPageBody}
|
||||||
innerClassName={styles.calendarInnerPageBody}
|
innerClassName={styles.calendarInnerPageBody}
|
||||||
>
|
>
|
||||||
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||||
{hasSeries && <Legend />}
|
{hasSeries && <Legend />}
|
||||||
</PageContentBody>
|
</PageContentBody>
|
||||||
|
|
||||||
<CalendarLinkModal
|
<CalendarLinkModal
|
||||||
isOpen={isCalendarLinkModalOpen}
|
isOpen={isCalendarLinkModalOpen}
|
||||||
onModalClose={handleGetCalendarLinkModalClose}
|
onModalClose={handleGetCalendarLinkModalClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CalendarOptionsModal
|
<CalendarOptionsModal
|
||||||
isOpen={isOptionsModalOpen}
|
isOpen={isOptionsModalOpen}
|
||||||
onModalClose={handleOptionsModalClose}
|
onModalClose={handleOptionsModalClose}
|
||||||
/>
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
</QueueDetails>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import moment from 'moment';
|
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
@@ -12,8 +12,8 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
|||||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import useSeries from 'Series/useSeries';
|
import useSeries from 'Series/useSeries';
|
||||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
|
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
||||||
import formatTime from 'Utilities/Date/formatTime';
|
import formatTime from 'Utilities/Date/formatTime';
|
||||||
import padNumber from 'Utilities/Number/padNumber';
|
import padNumber from 'Utilities/Number/padNumber';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
@@ -58,9 +58,9 @@ function CalendarEvent(props: CalendarEventProps) {
|
|||||||
|
|
||||||
const series = useSeries(seriesId);
|
const series = useSeries(seriesId);
|
||||||
const episodeFile = useEpisodeFile(episodeFileId);
|
const episodeFile = useEpisodeFile(episodeFileId);
|
||||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
const queueItem = useQueueItemForEpisode(id);
|
||||||
|
|
||||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
const { timeFormat, enableColorImpairedMode, timeZone } = useSelector(
|
||||||
createUISettingsSelector()
|
createUISettingsSelector()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,8 +88,11 @@ function CalendarEvent(props: CalendarEventProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = moment(airDateUtc);
|
const startTime = convertToTimezone(airDateUtc, timeZone);
|
||||||
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
const endTime = convertToTimezone(airDateUtc, timeZone).add(
|
||||||
|
series.runtime,
|
||||||
|
'minutes'
|
||||||
|
);
|
||||||
const isDownloading = !!(queueItem || grabbed);
|
const isDownloading = !!(queueItem || grabbed);
|
||||||
const isMonitored = series.monitored && monitored;
|
const isMonitored = series.monitored && monitored;
|
||||||
const statusStyle = getStatusStyle(
|
const statusStyle = getStatusStyle(
|
||||||
@@ -217,9 +220,10 @@ function CalendarEvent(props: CalendarEventProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className={styles.airTime}>
|
<div className={styles.airTime}>
|
||||||
{formatTime(airDateUtc, timeFormat)} -{' '}
|
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '}
|
||||||
{formatTime(endTime.toISOString(), timeFormat, {
|
{formatTime(endTime.toISOString(), timeFormat, {
|
||||||
includeMinuteZero: true,
|
includeMinuteZero: true,
|
||||||
|
timeZone,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import moment from 'moment';
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
@@ -12,23 +11,13 @@ import { icons, kinds } from 'Helpers/Props';
|
|||||||
import useSeries from 'Series/useSeries';
|
import useSeries from 'Series/useSeries';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
import { CalendarItem } from 'typings/Calendar';
|
import { CalendarItem } from 'typings/Calendar';
|
||||||
|
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
||||||
import formatTime from 'Utilities/Date/formatTime';
|
import formatTime from 'Utilities/Date/formatTime';
|
||||||
import padNumber from 'Utilities/Number/padNumber';
|
import padNumber from 'Utilities/Number/padNumber';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import CalendarEvent from './CalendarEvent';
|
import CalendarEvent from './CalendarEvent';
|
||||||
import styles from './CalendarEventGroup.css';
|
import styles from './CalendarEventGroup.css';
|
||||||
|
|
||||||
function createIsDownloadingSelector(episodeIds: number[]) {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.queue.details,
|
|
||||||
(details) => {
|
|
||||||
return details.items.some(
|
|
||||||
(item) => item.episodeId && episodeIds.includes(item.episodeId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CalendarEventGroupProps {
|
interface CalendarEventGroupProps {
|
||||||
episodeIds: number[];
|
episodeIds: number[];
|
||||||
seriesId: number;
|
seriesId: number;
|
||||||
@@ -42,10 +31,10 @@ function CalendarEventGroup({
|
|||||||
events,
|
events,
|
||||||
onEventModalOpenToggle,
|
onEventModalOpenToggle,
|
||||||
}: CalendarEventGroupProps) {
|
}: CalendarEventGroupProps) {
|
||||||
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds));
|
const isDownloading = useIsDownloadingEpisodes(episodeIds);
|
||||||
const series = useSeries(seriesId)!;
|
const series = useSeries(seriesId)!;
|
||||||
|
|
||||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
const { timeFormat, enableColorImpairedMode, timeZone } = useSelector(
|
||||||
createUISettingsSelector()
|
createUISettingsSelector()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -57,8 +46,11 @@ function CalendarEventGroup({
|
|||||||
const firstEpisode = events[0];
|
const firstEpisode = events[0];
|
||||||
const lastEpisode = events[events.length - 1];
|
const lastEpisode = events[events.length - 1];
|
||||||
const airDateUtc = firstEpisode.airDateUtc;
|
const airDateUtc = firstEpisode.airDateUtc;
|
||||||
const startTime = moment(airDateUtc);
|
const startTime = convertToTimezone(airDateUtc, timeZone);
|
||||||
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
|
const endTime = convertToTimezone(lastEpisode.airDateUtc, timeZone).add(
|
||||||
|
series.runtime,
|
||||||
|
'minutes'
|
||||||
|
);
|
||||||
const seasonNumber = firstEpisode.seasonNumber;
|
const seasonNumber = firstEpisode.seasonNumber;
|
||||||
|
|
||||||
const { allDownloaded, anyGrabbed, anyMonitored, allAbsoluteEpisodeNumbers } =
|
const { allDownloaded, anyGrabbed, anyMonitored, allAbsoluteEpisodeNumbers } =
|
||||||
@@ -205,9 +197,10 @@ function CalendarEventGroup({
|
|||||||
|
|
||||||
<div className={styles.airingInfo}>
|
<div className={styles.airingInfo}>
|
||||||
<div className={styles.airTime}>
|
<div className={styles.airTime}>
|
||||||
{formatTime(airDateUtc, timeFormat)} -{' '}
|
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '}
|
||||||
{formatTime(endTime.toISOString(), timeFormat, {
|
{formatTime(endTime.toISOString(), timeFormat, {
|
||||||
includeMinuteZero: true,
|
includeMinuteZero: true,
|
||||||
|
timeZone,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
interface CalendarEventQueueDetailsProps {
|
interface CalendarEventQueueDetailsProps {
|
||||||
title: string;
|
title: string;
|
||||||
size: number;
|
size: number;
|
||||||
sizeleft: number;
|
sizeLeft: number;
|
||||||
estimatedCompletionTime?: string;
|
estimatedCompletionTime?: string;
|
||||||
status: string;
|
status: string;
|
||||||
trackedDownloadState: QueueTrackedDownloadState;
|
trackedDownloadState: QueueTrackedDownloadState;
|
||||||
@@ -22,7 +22,7 @@ interface CalendarEventQueueDetailsProps {
|
|||||||
function CalendarEventQueueDetails({
|
function CalendarEventQueueDetails({
|
||||||
title,
|
title,
|
||||||
size,
|
size,
|
||||||
sizeleft,
|
sizeLeft,
|
||||||
estimatedCompletionTime,
|
estimatedCompletionTime,
|
||||||
status,
|
status,
|
||||||
trackedDownloadState,
|
trackedDownloadState,
|
||||||
@@ -30,13 +30,13 @@ function CalendarEventQueueDetails({
|
|||||||
statusMessages,
|
statusMessages,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}: CalendarEventQueueDetailsProps) {
|
}: CalendarEventQueueDetailsProps) {
|
||||||
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueueDetails
|
<QueueDetails
|
||||||
title={title}
|
title={title}
|
||||||
size={size}
|
size={size}
|
||||||
sizeleft={sizeleft}
|
sizeLeft={sizeLeft}
|
||||||
estimatedCompletionTime={estimatedCompletionTime}
|
estimatedCompletionTime={estimatedCompletionTime}
|
||||||
status={status}
|
status={status}
|
||||||
trackedDownloadState={trackedDownloadState}
|
trackedDownloadState={trackedDownloadState}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import NumberInput, { NumberInputChanged } from './NumberInput';
|
||||||
|
|
||||||
|
export interface FloatInputProps {
|
||||||
|
name: string;
|
||||||
|
value?: number | null;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
onChange: (change: NumberInputChanged) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FloatInput(props: FloatInputProps) {
|
||||||
|
return <NumberInput {...props} isFloat={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FloatInput;
|
||||||
@@ -7,6 +7,7 @@ import translate from 'Utilities/String/translate';
|
|||||||
import AutoCompleteInput, { AutoCompleteInputProps } from './AutoCompleteInput';
|
import AutoCompleteInput, { AutoCompleteInputProps } from './AutoCompleteInput';
|
||||||
import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
|
import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
|
||||||
import CheckInput, { CheckInputProps } from './CheckInput';
|
import CheckInput, { CheckInputProps } from './CheckInput';
|
||||||
|
import FloatInput, { FloatInputProps } from './FloatInput';
|
||||||
import { FormInputButtonProps } from './FormInputButton';
|
import { FormInputButtonProps } from './FormInputButton';
|
||||||
import FormInputHelpText from './FormInputHelpText';
|
import FormInputHelpText from './FormInputHelpText';
|
||||||
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
|
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
|
||||||
@@ -65,7 +66,7 @@ const componentMap: Record<InputType, ElementType> = {
|
|||||||
downloadClientSelect: DownloadClientSelectInput,
|
downloadClientSelect: DownloadClientSelectInput,
|
||||||
dynamicSelect: ProviderDataSelectInput,
|
dynamicSelect: ProviderDataSelectInput,
|
||||||
file: TextInput,
|
file: TextInput,
|
||||||
float: NumberInput,
|
float: FloatInput,
|
||||||
indexerFlagsSelect: IndexerFlagsSelectInput,
|
indexerFlagsSelect: IndexerFlagsSelectInput,
|
||||||
indexerSelect: IndexerSelectInput,
|
indexerSelect: IndexerSelectInput,
|
||||||
keyValueList: KeyValueListInput,
|
keyValueList: KeyValueListInput,
|
||||||
@@ -110,7 +111,7 @@ type PickProps<V, C extends InputType> = C extends 'text'
|
|||||||
: C extends 'file'
|
: C extends 'file'
|
||||||
? TextInputProps
|
? TextInputProps
|
||||||
: C extends 'float'
|
: C extends 'float'
|
||||||
? TextInputProps
|
? FloatInputProps
|
||||||
: C extends 'indexerFlagsSelect'
|
: C extends 'indexerFlagsSelect'
|
||||||
? IndexerFlagsSelectInputProps
|
? IndexerFlagsSelectInputProps
|
||||||
: C extends 'indexerSelect'
|
: C extends 'indexerSelect'
|
||||||
|
|||||||
@@ -24,13 +24,17 @@ function parseValue(
|
|||||||
return newValue;
|
return newValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NumberInputChanged extends InputChanged<number | null> {
|
||||||
|
isFloat?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NumberInputProps
|
export interface NumberInputProps
|
||||||
extends Omit<TextInputProps, 'value' | 'onChange'> {
|
extends Omit<TextInputProps, 'value' | 'onChange'> {
|
||||||
value?: number | null;
|
value?: number | null;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
isFloat?: boolean;
|
isFloat?: boolean;
|
||||||
onChange: (input: InputChanged<number | null>) => void;
|
onChange: (change: NumberInputChanged) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NumberInput({
|
function NumberInput({
|
||||||
@@ -50,11 +54,14 @@ function NumberInput({
|
|||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
({ name, value: newValue }: InputChanged<string>) => {
|
({ name, value: newValue }: InputChanged<string>) => {
|
||||||
setValue(newValue);
|
const parsedValue = parseValue(newValue, isFloat, min, max);
|
||||||
|
|
||||||
|
setValue(parsedValue == null ? '' : parsedValue.toString());
|
||||||
|
|
||||||
onChange({
|
onChange({
|
||||||
name,
|
name,
|
||||||
value: parseValue(newValue, isFloat, min, max),
|
value: parsedValue,
|
||||||
|
isFloat,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isFloat, min, max, onChange, setValue]
|
[isFloat, min, max, onChange, setValue]
|
||||||
@@ -75,6 +82,7 @@ function NumberInput({
|
|||||||
onChange({
|
onChange({
|
||||||
name,
|
name,
|
||||||
value: parsedValue,
|
value: parsedValue,
|
||||||
|
isFloat,
|
||||||
});
|
});
|
||||||
|
|
||||||
isFocused.current = false;
|
isFocused.current = false;
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import { addTag } from 'Store/Actions/tagActions';
|
|||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||||
import { InputChanged } from 'typings/inputs';
|
import { InputChanged } from 'typings/inputs';
|
||||||
import sortByProp from 'Utilities/Array/sortByProp';
|
import sortByProp from 'Utilities/Array/sortByProp';
|
||||||
import TagInput, { TagBase } from './TagInput';
|
import TagInput, { TagBase, TagInputProps } from './TagInput';
|
||||||
|
|
||||||
interface SeriesTag extends TagBase {
|
interface SeriesTag extends TagBase {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeriesTagInputProps<V> {
|
export interface SeriesTagInputProps<V>
|
||||||
|
extends Omit<
|
||||||
|
TagInputProps<SeriesTag>,
|
||||||
|
'tags' | 'tagList' | 'onTagAdd' | 'onTagDelete' | 'onChange'
|
||||||
|
> {
|
||||||
name: string;
|
name: string;
|
||||||
value: V;
|
value: V;
|
||||||
onChange: (change: InputChanged<V>) => void;
|
onChange: (change: InputChanged<V>) => void;
|
||||||
@@ -63,6 +67,7 @@ export default function SeriesTagInput<V extends number | number[]>({
|
|||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
...otherProps
|
||||||
}: SeriesTagInputProps<V>) {
|
}: SeriesTagInputProps<V>) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const isArray = Array.isArray(value);
|
const isArray = Array.isArray(value);
|
||||||
@@ -135,6 +140,7 @@ export default function SeriesTagInput<V extends number | number[]>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TagInput
|
<TagInput
|
||||||
|
{...otherProps}
|
||||||
name={name}
|
name={name}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
tagList={tagList}
|
tagList={tagList}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
color: var(--warningColor);
|
color: var(--warningColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
color: var(--primaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
.purple {
|
.purple {
|
||||||
color: var(--purple);
|
color: var(--purple);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -6,6 +6,7 @@ interface CssExports {
|
|||||||
'disabled': string;
|
'disabled': string;
|
||||||
'info': string;
|
'info': string;
|
||||||
'pink': string;
|
'pink': string;
|
||||||
|
'primary': string;
|
||||||
'purple': string;
|
'purple': string;
|
||||||
'success': string;
|
'success': string;
|
||||||
'warning': string;
|
'warning': string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
.modal {
|
.modal {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
max-width: 90%;
|
||||||
max-height: 90%;
|
max-height: 90%;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
.header {
|
.header {
|
||||||
z-index: 3;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@@ -7,6 +7,40 @@
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebarHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: $headerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoLink {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCloseButton {
|
||||||
|
composes: button from '~Components/Link/IconButton.css';
|
||||||
|
|
||||||
|
margin-right: 15px;
|
||||||
|
color: #e1e2e3;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--sonarrBlue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
// 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 {
|
||||||
|
'logo': string;
|
||||||
|
'logoContainer': string;
|
||||||
|
'logoLink': string;
|
||||||
'sidebar': string;
|
'sidebar': string;
|
||||||
|
'sidebarCloseButton': string;
|
||||||
'sidebarContainer': string;
|
'sidebarContainer': string;
|
||||||
|
'sidebarHeader': string;
|
||||||
}
|
}
|
||||||
export const cssExports: CssExports;
|
export const cssExports: CssExports;
|
||||||
export default cssExports;
|
export default cssExports;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -11,6 +10,8 @@ import { useDispatch } from 'react-redux';
|
|||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import QueueStatus from 'Activity/Queue/Status/QueueStatus';
|
import QueueStatus from 'Activity/Queue/Status/QueueStatus';
|
||||||
import { IconName } from 'Components/Icon';
|
import { IconName } from 'Components/Icon';
|
||||||
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
import OverlayScroller from 'Components/Scroller/OverlayScroller';
|
import OverlayScroller from 'Components/Scroller/OverlayScroller';
|
||||||
import Scroller from 'Components/Scroller/Scroller';
|
import Scroller from 'Components/Scroller/Scroller';
|
||||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||||
@@ -230,10 +231,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
|||||||
transition: 'none',
|
transition: 'none',
|
||||||
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
|
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
|
||||||
});
|
});
|
||||||
const [sidebarStyle, setSidebarStyle] = useState({
|
|
||||||
top: dimensions.headerHeight,
|
|
||||||
height: `${window.innerHeight - HEADER_HEIGHT}px`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const urlBase = window.Sonarr.urlBase;
|
const urlBase = window.Sonarr.urlBase;
|
||||||
const pathname = urlBase
|
const pathname = urlBase
|
||||||
@@ -299,22 +296,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
|||||||
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
|
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleWindowScroll = useCallback(() => {
|
|
||||||
const windowScroll =
|
|
||||||
window.scrollY == null
|
|
||||||
? document.documentElement.scrollTop
|
|
||||||
: window.scrollY;
|
|
||||||
const sidebarTop = Math.max(HEADER_HEIGHT - windowScroll, 0);
|
|
||||||
const sidebarHeight = window.innerHeight - sidebarTop;
|
|
||||||
|
|
||||||
if (isSmallScreen) {
|
|
||||||
setSidebarStyle({
|
|
||||||
top: `${sidebarTop}px`,
|
|
||||||
height: `${sidebarHeight}px`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isSmallScreen]);
|
|
||||||
|
|
||||||
const handleTouchStart = useCallback(
|
const handleTouchStart = useCallback(
|
||||||
(event: TouchEvent) => {
|
(event: TouchEvent) => {
|
||||||
const touches = event.touches;
|
const touches = event.touches;
|
||||||
@@ -396,10 +377,13 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
|||||||
touchStartY.current = null;
|
touchStartY.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSidebarClosePress = useCallback(() => {
|
||||||
|
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
window.addEventListener('click', handleWindowClick, { capture: true });
|
window.addEventListener('click', handleWindowClick, { capture: true });
|
||||||
window.addEventListener('scroll', handleWindowScroll);
|
|
||||||
window.addEventListener('touchstart', handleTouchStart);
|
window.addEventListener('touchstart', handleTouchStart);
|
||||||
window.addEventListener('touchmove', handleTouchMove);
|
window.addEventListener('touchmove', handleTouchMove);
|
||||||
window.addEventListener('touchend', handleTouchEnd);
|
window.addEventListener('touchend', handleTouchEnd);
|
||||||
@@ -408,7 +392,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('click', handleWindowClick, { capture: true });
|
window.removeEventListener('click', handleWindowClick, { capture: true });
|
||||||
window.removeEventListener('scroll', handleWindowScroll);
|
|
||||||
window.removeEventListener('touchstart', handleTouchStart);
|
window.removeEventListener('touchstart', handleTouchStart);
|
||||||
window.removeEventListener('touchmove', handleTouchMove);
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
window.removeEventListener('touchend', handleTouchEnd);
|
window.removeEventListener('touchend', handleTouchEnd);
|
||||||
@@ -417,7 +400,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
|||||||
}, [
|
}, [
|
||||||
isSmallScreen,
|
isSmallScreen,
|
||||||
handleWindowClick,
|
handleWindowClick,
|
||||||
handleWindowScroll,
|
|
||||||
handleTouchStart,
|
handleTouchStart,
|
||||||
handleTouchMove,
|
handleTouchMove,
|
||||||
handleTouchEnd,
|
handleTouchEnd,
|
||||||
@@ -456,13 +438,37 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
className={classNames(styles.sidebarContainer)}
|
className={styles.sidebarContainer}
|
||||||
style={containerStyle}
|
style={containerStyle}
|
||||||
>
|
>
|
||||||
|
{isSmallScreen ? (
|
||||||
|
<div className={styles.sidebarHeader}>
|
||||||
|
<div className={styles.logoContainer}>
|
||||||
|
<Link className={styles.logoLink} to="/">
|
||||||
|
<img
|
||||||
|
className={styles.logo}
|
||||||
|
src={`${window.Sonarr.urlBase}/Content/Images/logo.svg`}
|
||||||
|
alt="Sonarr Logo"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
className={styles.sidebarCloseButton}
|
||||||
|
name={icons.CLOSE}
|
||||||
|
aria-label={translate('Close')}
|
||||||
|
size={20}
|
||||||
|
onPress={handleSidebarClosePress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ScrollerComponent
|
<ScrollerComponent
|
||||||
className={styles.sidebar}
|
className={styles.sidebar}
|
||||||
scrollDirection="vertical"
|
scrollDirection="vertical"
|
||||||
style={sidebarStyle}
|
style={{
|
||||||
|
height: `${window.innerHeight - HEADER_HEIGHT}px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{LINKS.map((link) => {
|
{LINKS.map((link) => {
|
||||||
|
|||||||
@@ -3,19 +3,18 @@ import {
|
|||||||
HubConnectionBuilder,
|
HubConnectionBuilder,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
} from '@microsoft/signalr';
|
} from '@microsoft/signalr';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import ModelBase from 'App/ModelBase';
|
import ModelBase from 'App/ModelBase';
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import Command from 'Commands/Command';
|
import Command from 'Commands/Command';
|
||||||
import { setAppValue, setVersion } from 'Store/Actions/appActions';
|
import { setAppValue, setVersion } from 'Store/Actions/appActions';
|
||||||
import { removeItem, update, updateItem } from 'Store/Actions/baseActions';
|
import { removeItem, updateItem } from 'Store/Actions/baseActions';
|
||||||
import {
|
import {
|
||||||
fetchCommands,
|
fetchCommands,
|
||||||
finishCommand,
|
finishCommand,
|
||||||
updateCommand,
|
updateCommand,
|
||||||
} from 'Store/Actions/commandActions';
|
} from 'Store/Actions/commandActions';
|
||||||
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
|
|
||||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||||
import { fetchSeries } from 'Store/Actions/seriesActions';
|
import { fetchSeries } from 'Store/Actions/seriesActions';
|
||||||
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
|
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
|
||||||
@@ -33,15 +32,13 @@ interface SignalRMessage {
|
|||||||
resource: ModelBase;
|
resource: ModelBase;
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
|
version: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SignalRListener() {
|
function SignalRListener() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const isQueuePopulated = useSelector(
|
|
||||||
(state: AppState) => state.queue.paged.isPopulated
|
|
||||||
);
|
|
||||||
|
|
||||||
const connection = useRef<HubConnection | null>(null);
|
const connection = useRef<HubConnection | null>(null);
|
||||||
|
|
||||||
const handleStartFail = useRef((error: unknown) => {
|
const handleStartFail = useRef((error: unknown) => {
|
||||||
@@ -97,9 +94,14 @@ function SignalRListener() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleReceiveMessage = useRef((message: SignalRMessage) => {
|
const handleReceiveMessage = useRef((message: SignalRMessage) => {
|
||||||
console.debug('[signalR] received', message.name, message.body);
|
console.debug(
|
||||||
|
`[signalR] received ${message.name}${
|
||||||
|
message.version ? ` v${message.version}` : ''
|
||||||
|
}`,
|
||||||
|
message.body
|
||||||
|
);
|
||||||
|
|
||||||
const { name, body } = message;
|
const { name, body, version = 0 } = message;
|
||||||
|
|
||||||
if (name === 'calendar') {
|
if (name === 'calendar') {
|
||||||
if (body.action === 'updated') {
|
if (body.action === 'updated') {
|
||||||
@@ -235,20 +237,36 @@ function SignalRListener() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'queue') {
|
if (name === 'queue') {
|
||||||
if (isQueuePopulated) {
|
if (version < 5) {
|
||||||
dispatch(fetchQueue());
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'queue/details') {
|
if (name === 'queue/details') {
|
||||||
dispatch(fetchQueueDetails());
|
if (version < 5) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/queue/details'] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'queue/status') {
|
if (name === 'queue/status') {
|
||||||
dispatch(update({ section: 'queue.status', data: body.resource }));
|
if (version < 5) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusDetails = queryClient.getQueriesData({
|
||||||
|
queryKey: ['/queue/status'],
|
||||||
|
});
|
||||||
|
|
||||||
|
statusDetails.forEach(([queryKey]) => {
|
||||||
|
queryClient.setQueryData(queryKey, () => body.resource);
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ function RelativeDateCell(props: RelativeDateCellProps) {
|
|||||||
date,
|
date,
|
||||||
includeSeconds = false,
|
includeSeconds = false,
|
||||||
includeTime = false,
|
includeTime = false,
|
||||||
|
|
||||||
component: Component = TableRowCell,
|
component: Component = TableRowCell,
|
||||||
...otherProps
|
...otherProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
import { Tag } from 'App/State/TagsAppState';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
import sortByProp from 'Utilities/Array/sortByProp';
|
import sortByProp from 'Utilities/Array/sortByProp';
|
||||||
import Label from './Label';
|
import Label, { LabelProps } from './Label';
|
||||||
import styles from './TagList.css';
|
import styles from './TagList.css';
|
||||||
|
|
||||||
interface TagListProps {
|
interface TagListProps {
|
||||||
tags: number[];
|
tags: number[];
|
||||||
tagList: Tag[];
|
tagList: Tag[];
|
||||||
|
kind?: Extract<Kind, LabelProps['kind']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TagList({ tags, tagList }: TagListProps) {
|
export default function TagList({
|
||||||
|
tags,
|
||||||
|
tagList,
|
||||||
|
kind = kinds.INFO,
|
||||||
|
}: TagListProps) {
|
||||||
const sortedTags = tags
|
const sortedTags = tags
|
||||||
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
||||||
.filter((tag) => !!tag)
|
.filter((tag) => !!tag)
|
||||||
@@ -20,7 +26,7 @@ function TagList({ tags, tagList }: TagListProps) {
|
|||||||
<div className={styles.tags}>
|
<div className={styles.tags}>
|
||||||
{sortedTags.map((tag) => {
|
{sortedTags.map((tag) => {
|
||||||
return (
|
return (
|
||||||
<Label key={tag.id} kind={kinds.INFO}>
|
<Label key={tag.id} kind={kind}>
|
||||||
{tag.label}
|
{tag.label}
|
||||||
</Label>
|
</Label>
|
||||||
);
|
);
|
||||||
@@ -28,5 +34,3 @@ function TagList({ tags, tagList }: TagListProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TagList;
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import ProgressBar from 'Components/ProgressBar';
|
import ProgressBar from 'Components/ProgressBar';
|
||||||
@@ -7,7 +7,6 @@ import Episode from 'Episode/Episode';
|
|||||||
import useEpisode, { EpisodeEntity } from 'Episode/useEpisode';
|
import useEpisode, { EpisodeEntity } from 'Episode/useEpisode';
|
||||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
|
||||||
import isBefore from 'Utilities/Date/isBefore';
|
import isBefore from 'Utilities/Date/isBefore';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import EpisodeQuality from './EpisodeQuality';
|
import EpisodeQuality from './EpisodeQuality';
|
||||||
@@ -30,7 +29,7 @@ function EpisodeStatus({
|
|||||||
grabbed = false,
|
grabbed = false,
|
||||||
} = useEpisode(episodeId, episodeEntity) as Episode;
|
} = useEpisode(episodeId, episodeEntity) as Episode;
|
||||||
|
|
||||||
const queueItem = useSelector(createQueueItemSelectorForHook(episodeId));
|
const queueItem = useQueueItemForEpisode(episodeId);
|
||||||
const episodeFile = useEpisodeFile(episodeFileId);
|
const episodeFile = useEpisodeFile(episodeFileId);
|
||||||
|
|
||||||
const hasEpisodeFile = !!episodeFile;
|
const hasEpisodeFile = !!episodeFile;
|
||||||
@@ -38,9 +37,9 @@ function EpisodeStatus({
|
|||||||
const hasAired = isBefore(airDateUtc);
|
const hasAired = isBefore(airDateUtc);
|
||||||
|
|
||||||
if (isQueued) {
|
if (isQueued) {
|
||||||
const { sizeleft, size } = queueItem;
|
const { sizeLeft, size } = queueItem;
|
||||||
|
|
||||||
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.center}>
|
<div className={styles.center}>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import Episode from './Episode';
|
||||||
|
|
||||||
|
export type EpisodeEntity =
|
||||||
|
| 'calendar'
|
||||||
|
| 'episodes'
|
||||||
|
| 'interactiveImport.episodes'
|
||||||
|
| 'wanted.cutoffUnmet'
|
||||||
|
| 'wanted.missing';
|
||||||
|
|
||||||
|
function getEpisodes(episodeIds: number[], episodes: Episode[]) {
|
||||||
|
return episodeIds.reduce<Episode[]>((acc, id) => {
|
||||||
|
const episode = episodes.find((episode) => episode.id === id);
|
||||||
|
|
||||||
|
if (episode) {
|
||||||
|
acc.push(episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEpisodeSelector(episodeIds: number[]) {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.episodes.items,
|
||||||
|
(episodes) => {
|
||||||
|
return getEpisodes(episodeIds, episodes);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCalendarEpisodeSelector(episodeIds: number[]) {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.calendar.items as Episode[],
|
||||||
|
(episodes) => {
|
||||||
|
return getEpisodes(episodeIds, episodes);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWantedCutoffUnmetEpisodeSelector(episodeIds: number[]) {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.wanted.cutoffUnmet.items,
|
||||||
|
(episodes) => {
|
||||||
|
return getEpisodes(episodeIds, episodes);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWantedMissingEpisodeSelector(episodeIds: number[]) {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.wanted.missing.items,
|
||||||
|
(episodes) => {
|
||||||
|
return getEpisodes(episodeIds, episodes);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useEpisodes(
|
||||||
|
episodeIds: number[],
|
||||||
|
episodeEntity: EpisodeEntity
|
||||||
|
) {
|
||||||
|
let selector = createEpisodeSelector;
|
||||||
|
|
||||||
|
switch (episodeEntity) {
|
||||||
|
case 'calendar':
|
||||||
|
selector = createCalendarEpisodeSelector;
|
||||||
|
break;
|
||||||
|
case 'wanted.cutoffUnmet':
|
||||||
|
selector = createWantedCutoffUnmetEpisodeSelector;
|
||||||
|
break;
|
||||||
|
case 'wanted.missing':
|
||||||
|
selector = createWantedMissingEpisodeSelector;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return useSelector(selector(episodeIds));
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ export default function AuthenticationRequiredModalContent() {
|
|||||||
dispatch(fetchGeneralSettings());
|
dispatch(fetchGeneralSettings());
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
dispatch(clearPendingChanges());
|
dispatch(clearPendingChanges({ section: `settings.${SECTION}` }));
|
||||||
};
|
};
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Error } from 'App/State/AppSectionState';
|
import { Error } from 'App/State/AppSectionState';
|
||||||
import fetchJson, {
|
import fetchJson, { FetchJsonOptions } from 'Utilities/Fetch/fetchJson';
|
||||||
apiRoot,
|
import getQueryPath from 'Utilities/Fetch/getQueryPath';
|
||||||
FetchJsonOptions,
|
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
|
||||||
} from 'Utilities/Fetch/fetchJson';
|
|
||||||
|
|
||||||
interface MutationOptions<T, TData>
|
interface MutationOptions<T, TData>
|
||||||
extends Omit<FetchJsonOptions<TData>, 'method'> {
|
extends Omit<FetchJsonOptions<TData>, 'method'> {
|
||||||
method: 'POST' | 'PUT' | 'DELETE';
|
method: 'POST' | 'PUT' | 'DELETE';
|
||||||
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
|
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
|
||||||
|
queryParams?: QueryParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
||||||
const requestOptions = useMemo(() => {
|
const requestOptions = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
path: apiRoot + options.path,
|
path: getQueryPath(options.path) + getQueryString(options.queryParams),
|
||||||
headers: {
|
headers: {
|
||||||
...options.headers,
|
...options.headers,
|
||||||
'X-Api-Key': window.Sonarr.apiKey,
|
'X-Api-Key': window.Sonarr.apiKey,
|
||||||
|
'X-Sonarr-Client': 'Sonarr',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
||||||
return useMutation<T, Error, TData>({
|
return useMutation<T, Error, TData>({
|
||||||
...options.mutationOptions,
|
...options.mutationOptions,
|
||||||
mutationFn: async (data: TData) =>
|
mutationFn: async (data?: TData) => {
|
||||||
fetchJson<T, TData>({ ...requestOptions, body: data }),
|
const { path, ...otherOptions } = requestOptions;
|
||||||
|
|
||||||
|
return fetchJson<T, TData>({ path, ...otherOptions, body: data });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,22 +15,26 @@ export interface QueryOptions<T> extends FetchJsonOptions<unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const useApiQuery = <T>(options: QueryOptions<T>) => {
|
const useApiQuery = <T>(options: QueryOptions<T>) => {
|
||||||
const requestOptions = useMemo(() => {
|
const { queryKey, requestOptions } = useMemo(() => {
|
||||||
const { path: path, queryOptions, queryParams, ...otherOptions } = options;
|
const { path: path, queryOptions, queryParams, ...otherOptions } = options;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...otherOptions,
|
queryKey: [path, queryParams],
|
||||||
path: getQueryPath(path) + getQueryString(queryParams),
|
requestOptions: {
|
||||||
headers: {
|
...otherOptions,
|
||||||
...options.headers,
|
path: getQueryPath(path) + getQueryString(queryParams),
|
||||||
'X-Api-Key': window.Sonarr.apiKey,
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
'X-Api-Key': window.Sonarr.apiKey,
|
||||||
|
'X-Sonarr-Client': 'Sonarr',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
...options.queryOptions,
|
...options.queryOptions,
|
||||||
queryKey: [requestOptions.path],
|
queryKey,
|
||||||
queryFn: async ({ signal }) =>
|
queryFn: async ({ signal }) =>
|
||||||
fetchJson<T, unknown>({ ...requestOptions, signal }),
|
fetchJson<T, unknown>({ ...requestOptions, signal }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { StateCreator } from 'zustand';
|
||||||
|
import { PersistOptions } from 'zustand/middleware';
|
||||||
|
import Column from 'Components/Table/Column';
|
||||||
|
import { createPersist } from 'Helpers/createPersist';
|
||||||
|
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||||
|
|
||||||
|
type TSettingsWithoutColumns = object;
|
||||||
|
|
||||||
|
interface TSettingsWithColumns {
|
||||||
|
columns: Column[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type TSettingd = TSettingsWithoutColumns | TSettingsWithColumns;
|
||||||
|
|
||||||
|
export interface PageableOptions {
|
||||||
|
pageSize: number;
|
||||||
|
selectedFilterKey: string | number;
|
||||||
|
sortKey: string;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
columns: Column[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OptionChanged<T> = {
|
||||||
|
name: keyof T;
|
||||||
|
value: T[keyof T];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOptionsStore = <T extends TSettingd>(
|
||||||
|
name: string,
|
||||||
|
state: StateCreator<T>,
|
||||||
|
options: Omit<PersistOptions<T>, 'name' | 'storage'> = {}
|
||||||
|
) => {
|
||||||
|
const store = createPersist<T>(name, state, {
|
||||||
|
merge,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const useOptions = () => {
|
||||||
|
return store((state) => state);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useOption = <K extends keyof T>(key: K) => {
|
||||||
|
return store((state) => state[key]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setOptions = (options: Partial<T>) => {
|
||||||
|
store.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
...options,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setOption = <K extends keyof T>(key: K, value: T[K]) => {
|
||||||
|
store.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
useOptions,
|
||||||
|
useOption,
|
||||||
|
setOptions,
|
||||||
|
setOption,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const merge = <T extends TSettingd>(
|
||||||
|
persistedState: unknown,
|
||||||
|
currentState: T
|
||||||
|
) => {
|
||||||
|
if ('columns' in currentState) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
...mergeColumns(persistedState, currentState),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
...((persistedState as T) ?? {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeColumns = <T extends { columns: Column[] }>(
|
||||||
|
persistedState: unknown,
|
||||||
|
currentState: T
|
||||||
|
) => {
|
||||||
|
const currentColumns = currentState.columns;
|
||||||
|
const persistedColumns = (persistedState as T).columns;
|
||||||
|
const columns: Column[] = [];
|
||||||
|
|
||||||
|
// Add persisted columns in the same order they're currently in
|
||||||
|
// as long as they haven't been removed.
|
||||||
|
|
||||||
|
persistedColumns.forEach((persistedColumn) => {
|
||||||
|
const column = currentColumns.find((i) => i.name === persistedColumn.name);
|
||||||
|
|
||||||
|
if (column) {
|
||||||
|
const newColumn: Partial<Column> = {};
|
||||||
|
|
||||||
|
// We can't use a spread operator or Object.assign to clone the column
|
||||||
|
// or any accessors are lost and can break translations.
|
||||||
|
for (const prop of Object.keys(column)) {
|
||||||
|
const attributes = Object.getOwnPropertyDescriptor(column, prop);
|
||||||
|
|
||||||
|
if (!attributes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(newColumn, prop, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
newColumn.isVisible = persistedColumn.isVisible;
|
||||||
|
|
||||||
|
columns.push(newColumn as Column);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add any columns added to the app in the initial position.
|
||||||
|
currentColumns.forEach((currentColumn, index) => {
|
||||||
|
const persistedColumnIndex = persistedColumns.findIndex(
|
||||||
|
(i) => i.name === currentColumn.name
|
||||||
|
);
|
||||||
|
const column = Object.assign({}, currentColumn);
|
||||||
|
|
||||||
|
if (persistedColumnIndex === -1) {
|
||||||
|
columns.splice(index, 0, column);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(persistedState as T),
|
||||||
|
columns,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -3,11 +3,17 @@ import { useHistory } from 'react-router';
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface PageStore {
|
interface PageStore {
|
||||||
|
blocklist: number;
|
||||||
events: number;
|
events: number;
|
||||||
|
history: number;
|
||||||
|
queue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageStore = create<PageStore>(() => ({
|
const pageStore = create<PageStore>(() => ({
|
||||||
|
blocklist: 1,
|
||||||
events: 1,
|
events: 1,
|
||||||
|
history: 1,
|
||||||
|
queue: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const usePage = (kind: keyof PageStore) => {
|
const usePage = (kind: keyof PageStore) => {
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ interface PagedQueryResponse<T> {
|
|||||||
records: T[];
|
records: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RECORDS: never[] = [];
|
||||||
|
|
||||||
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
||||||
const requestOptions = useMemo(() => {
|
const { requestOptions, queryKey } = useMemo(() => {
|
||||||
const {
|
const {
|
||||||
path,
|
path,
|
||||||
page,
|
page,
|
||||||
@@ -40,27 +42,39 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...otherOptions,
|
queryKey: [
|
||||||
path:
|
path,
|
||||||
getQueryPath(path) +
|
queryParams,
|
||||||
getQueryString({
|
page,
|
||||||
...queryParams,
|
pageSize,
|
||||||
page,
|
sortKey,
|
||||||
pageSize,
|
sortDirection,
|
||||||
sortKey,
|
filters,
|
||||||
sortDirection,
|
],
|
||||||
filters,
|
requestOptions: {
|
||||||
}),
|
...otherOptions,
|
||||||
headers: {
|
path:
|
||||||
...options.headers,
|
getQueryPath(path) +
|
||||||
'X-Api-Key': window.Sonarr.apiKey,
|
getQueryString({
|
||||||
|
...queryParams,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
filters,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
'X-Api-Key': window.Sonarr.apiKey,
|
||||||
|
'X-Sonarr-Client': 'Sonarr',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
||||||
return useQuery({
|
const { data, ...query } = useQuery({
|
||||||
...options.queryOptions,
|
...options.queryOptions,
|
||||||
queryKey: [requestOptions.path],
|
queryKey,
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const response = await fetchJson<PagedQueryResponse<T>, unknown>({
|
const response = await fetchJson<PagedQueryResponse<T>, unknown>({
|
||||||
...requestOptions,
|
...requestOptions,
|
||||||
@@ -76,6 +90,13 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
records: data?.records ?? DEFAULT_RECORDS,
|
||||||
|
totalRecords: data?.totalRecords ?? 0,
|
||||||
|
totalPages: data?.totalPages ?? 0,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default usePagedApiQuery;
|
export default usePagedApiQuery;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
@@ -6,14 +7,42 @@ import themes from 'Styles/Themes';
|
|||||||
function createThemeSelector() {
|
function createThemeSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
|
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
|
||||||
(theme) => {
|
(theme) => theme
|
||||||
return theme;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const useTheme = () => {
|
const useTheme = () => {
|
||||||
return useSelector(createThemeSelector());
|
const selectedTheme = useSelector(createThemeSelector());
|
||||||
|
const [resolvedTheme, setResolvedTheme] = useState(selectedTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTheme !== 'auto') {
|
||||||
|
setResolvedTheme(selectedTheme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySystemTheme = () => {
|
||||||
|
setResolvedTheme(
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
applySystemTheme();
|
||||||
|
|
||||||
|
window
|
||||||
|
.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.addEventListener('change', applySystemTheme);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window
|
||||||
|
.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.removeEventListener('change', applySystemTheme);
|
||||||
|
};
|
||||||
|
}, [selectedTheme]);
|
||||||
|
|
||||||
|
return resolvedTheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useTheme;
|
export default useTheme;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { create, type StateCreator } from 'zustand';
|
import { create, type StateCreator } from 'zustand';
|
||||||
import { persist, type PersistOptions } from 'zustand/middleware';
|
import { persist, type PersistOptions } from 'zustand/middleware';
|
||||||
import Column from 'Components/Table/Column';
|
|
||||||
|
|
||||||
export const createPersist = <T>(
|
export const createPersist = <T>(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -19,56 +18,3 @@ export const createPersist = <T>(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mergeColumns = <T extends { columns: Column[] }>(
|
|
||||||
persistedState: unknown,
|
|
||||||
currentState: T
|
|
||||||
) => {
|
|
||||||
const currentColumns = currentState.columns;
|
|
||||||
const persistedColumns = (persistedState as T).columns;
|
|
||||||
const columns: Column[] = [];
|
|
||||||
|
|
||||||
// Add persisted columns in the same order they're currently in
|
|
||||||
// as long as they haven't been removed.
|
|
||||||
|
|
||||||
persistedColumns.forEach((persistedColumn) => {
|
|
||||||
const column = currentColumns.find((i) => i.name === persistedColumn.name);
|
|
||||||
|
|
||||||
if (column) {
|
|
||||||
const newColumn: Partial<Column> = {};
|
|
||||||
|
|
||||||
// We can't use a spread operator or Object.assign to clone the column
|
|
||||||
// or any accessors are lost and can break translations.
|
|
||||||
for (const prop of Object.keys(column)) {
|
|
||||||
const attributes = Object.getOwnPropertyDescriptor(column, prop);
|
|
||||||
|
|
||||||
if (!attributes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.defineProperty(newColumn, prop, attributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
newColumn.isVisible = persistedColumn.isVisible;
|
|
||||||
|
|
||||||
columns.push(newColumn as Column);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add any columns added to the app in the initial position.
|
|
||||||
currentColumns.forEach((currentColumn, index) => {
|
|
||||||
const persistedColumnIndex = persistedColumns.findIndex(
|
|
||||||
(i) => i.name === currentColumn.name
|
|
||||||
);
|
|
||||||
const column = Object.assign({}, currentColumn);
|
|
||||||
|
|
||||||
if (persistedColumnIndex === -1) {
|
|
||||||
columns.splice(index, 0, column);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(persistedState as T),
|
|
||||||
columns,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
|||||||
onGrabPress,
|
onGrabPress,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { longDateFormat, timeFormat } = useSelector(
|
const { longDateFormat, timeFormat, timeZone } = useSelector(
|
||||||
createUISettingsSelector()
|
createUISettingsSelector()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -174,6 +174,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
|||||||
className={styles.age}
|
className={styles.age}
|
||||||
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
|
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
|
||||||
includeSeconds: true,
|
includeSeconds: true,
|
||||||
|
timeZone,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{formatAge(age, ageHours, ageMinutes)}
|
{formatAge(age, ageHours, ageMinutes)}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import Label from 'Components/Label';
|
import Label from 'Components/Label';
|
||||||
import { kinds, sizes } from 'Helpers/Props';
|
import { kinds, sizes } from 'Helpers/Props';
|
||||||
import createSeriesQueueItemsDetailsSelector, {
|
|
||||||
SeriesQueueDetails,
|
|
||||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
|
||||||
|
|
||||||
function getEpisodeCountKind(
|
function getEpisodeCountKind(
|
||||||
monitored: boolean,
|
monitored: boolean,
|
||||||
@@ -44,9 +41,7 @@ function SeasonProgressLabel({
|
|||||||
episodeCount,
|
episodeCount,
|
||||||
episodeFileCount,
|
episodeFileCount,
|
||||||
}: SeasonProgressLabelProps) {
|
}: SeasonProgressLabelProps) {
|
||||||
const queueDetails: SeriesQueueDetails = useSelector(
|
const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
|
||||||
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
||||||
const text = newDownloads
|
const text = newDownloads
|
||||||
|
|||||||
@@ -57,11 +57,12 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: calc(3 * 50px);
|
max-height: calc(3 * 60px);
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-size: 50px;
|
font-size: 50px;
|
||||||
line-height: 50px;
|
line-height: 60px;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
line-clamp: 3;
|
line-clamp: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
|
|
||||||
.alternateTitlesIconContainer {
|
.alternateTitlesIconContainer {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
|
margin-bottom: 10px;
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import moment from 'moment';
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import * as commandNames from 'Commands/commandNames';
|
import * as commandNames from 'Commands/commandNames';
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
@@ -47,10 +48,6 @@ import {
|
|||||||
clearEpisodeFiles,
|
clearEpisodeFiles,
|
||||||
fetchEpisodeFiles,
|
fetchEpisodeFiles,
|
||||||
} from 'Store/Actions/episodeFileActions';
|
} from 'Store/Actions/episodeFileActions';
|
||||||
import {
|
|
||||||
clearQueueDetails,
|
|
||||||
fetchQueueDetails,
|
|
||||||
} from 'Store/Actions/queueActions';
|
|
||||||
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
|
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
|
||||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||||
@@ -380,7 +377,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
|||||||
const populate = useCallback(() => {
|
const populate = useCallback(() => {
|
||||||
dispatch(fetchEpisodes({ seriesId }));
|
dispatch(fetchEpisodes({ seriesId }));
|
||||||
dispatch(fetchEpisodeFiles({ seriesId }));
|
dispatch(fetchEpisodeFiles({ seriesId }));
|
||||||
dispatch(fetchQueueDetails({ seriesId }));
|
|
||||||
}, [seriesId, dispatch]);
|
}, [seriesId, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -394,7 +390,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
|||||||
unregisterPagePopulator(populate);
|
unregisterPagePopulator(populate);
|
||||||
dispatch(clearEpisodes());
|
dispatch(clearEpisodes());
|
||||||
dispatch(clearEpisodeFiles());
|
dispatch(clearEpisodeFiles());
|
||||||
dispatch(clearQueueDetails());
|
|
||||||
};
|
};
|
||||||
}, [populate, dispatch]);
|
}, [populate, dispatch]);
|
||||||
|
|
||||||
@@ -466,424 +461,435 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
|||||||
const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
|
const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent title={title}>
|
<QueueDetailsProvider seriesId={seriesId}>
|
||||||
<PageToolbar>
|
<PageContent title={title}>
|
||||||
<PageToolbarSection>
|
<PageToolbar>
|
||||||
<PageToolbarButton
|
<PageToolbarSection>
|
||||||
label={translate('RefreshAndScan')}
|
<PageToolbarButton
|
||||||
iconName={icons.REFRESH}
|
label={translate('RefreshAndScan')}
|
||||||
spinningName={icons.REFRESH}
|
iconName={icons.REFRESH}
|
||||||
title={translate('RefreshAndScanTooltip')}
|
spinningName={icons.REFRESH}
|
||||||
isSpinning={isRefreshing}
|
title={translate('RefreshAndScanTooltip')}
|
||||||
onPress={handleRefreshPress}
|
isSpinning={isRefreshing}
|
||||||
/>
|
onPress={handleRefreshPress}
|
||||||
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('SearchMonitored')}
|
|
||||||
iconName={icons.SEARCH}
|
|
||||||
isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
|
|
||||||
isSpinning={isSearching}
|
|
||||||
title={
|
|
||||||
hasMonitoredEpisodes
|
|
||||||
? undefined
|
|
||||||
: translate('NoMonitoredEpisodes')
|
|
||||||
}
|
|
||||||
onPress={handleSearchPress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PageToolbarSeparator />
|
|
||||||
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('PreviewRename')}
|
|
||||||
iconName={icons.ORGANIZE}
|
|
||||||
isDisabled={!hasEpisodeFiles}
|
|
||||||
onPress={handleOrganizePress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('ManageEpisodes')}
|
|
||||||
iconName={icons.EPISODE_FILE}
|
|
||||||
onPress={handleManageEpisodesPress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('History')}
|
|
||||||
iconName={icons.HISTORY}
|
|
||||||
isDisabled={!hasEpisodes}
|
|
||||||
onPress={handleSeriesHistoryPress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PageToolbarSeparator />
|
|
||||||
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('EpisodeMonitoring')}
|
|
||||||
iconName={icons.MONITORED}
|
|
||||||
onPress={handleMonitorOptionsPress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('Edit')}
|
|
||||||
iconName={icons.EDIT}
|
|
||||||
onPress={handleEditSeriesPress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('Delete')}
|
|
||||||
iconName={icons.DELETE}
|
|
||||||
onPress={handleDeleteSeriesPress}
|
|
||||||
/>
|
|
||||||
</PageToolbarSection>
|
|
||||||
|
|
||||||
<PageToolbarSection alignContent={align.RIGHT}>
|
|
||||||
<PageToolbarButton
|
|
||||||
label={
|
|
||||||
expandedState.allExpanded
|
|
||||||
? translate('CollapseAll')
|
|
||||||
: translate('ExpandAll')
|
|
||||||
}
|
|
||||||
iconName={expandIcon}
|
|
||||||
onPress={handleExpandAllPress}
|
|
||||||
/>
|
|
||||||
</PageToolbarSection>
|
|
||||||
</PageToolbar>
|
|
||||||
|
|
||||||
<PageContentBody innerClassName={styles.innerContentBody}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<div
|
|
||||||
className={styles.backdrop}
|
|
||||||
style={
|
|
||||||
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.backdropOverlay} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.headerContent}>
|
|
||||||
<SeriesPoster
|
|
||||||
className={styles.poster}
|
|
||||||
images={images}
|
|
||||||
size={500}
|
|
||||||
lazy={false}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.info}>
|
<PageToolbarButton
|
||||||
<div className={styles.titleRow}>
|
label={translate('SearchMonitored')}
|
||||||
<div className={styles.titleContainer}>
|
iconName={icons.SEARCH}
|
||||||
<div className={styles.toggleMonitoredContainer}>
|
isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
|
||||||
<MonitorToggleButton
|
isSpinning={isSearching}
|
||||||
className={styles.monitorToggleButton}
|
title={
|
||||||
monitored={monitored}
|
hasMonitoredEpisodes
|
||||||
isSaving={isSaving}
|
? undefined
|
||||||
size={40}
|
: translate('NoMonitoredEpisodes')
|
||||||
onPress={handleMonitorTogglePress}
|
}
|
||||||
/>
|
onPress={handleSearchPress}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className={styles.title}>{title}</div>
|
<PageToolbarSeparator />
|
||||||
|
|
||||||
{alternateTitles.length ? (
|
<PageToolbarButton
|
||||||
<div className={styles.alternateTitlesIconContainer}>
|
label={translate('PreviewRename')}
|
||||||
<Popover
|
iconName={icons.ORGANIZE}
|
||||||
anchor={
|
isDisabled={!hasEpisodeFiles}
|
||||||
<Icon name={icons.ALTERNATE_TITLES} size={20} />
|
onPress={handleOrganizePress}
|
||||||
}
|
/>
|
||||||
title={translate('AlternateTitles')}
|
|
||||||
body={
|
<PageToolbarButton
|
||||||
<SeriesAlternateTitles
|
label={translate('ManageEpisodes')}
|
||||||
alternateTitles={alternateTitles}
|
iconName={icons.EPISODE_FILE}
|
||||||
/>
|
onPress={handleManageEpisodesPress}
|
||||||
}
|
/>
|
||||||
position={tooltipPositions.BOTTOM}
|
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('History')}
|
||||||
|
iconName={icons.HISTORY}
|
||||||
|
isDisabled={!hasEpisodes}
|
||||||
|
onPress={handleSeriesHistoryPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageToolbarSeparator />
|
||||||
|
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('EpisodeMonitoring')}
|
||||||
|
iconName={icons.MONITORED}
|
||||||
|
onPress={handleMonitorOptionsPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('Edit')}
|
||||||
|
iconName={icons.EDIT}
|
||||||
|
onPress={handleEditSeriesPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('Delete')}
|
||||||
|
iconName={icons.DELETE}
|
||||||
|
onPress={handleDeleteSeriesPress}
|
||||||
|
/>
|
||||||
|
</PageToolbarSection>
|
||||||
|
|
||||||
|
<PageToolbarSection alignContent={align.RIGHT}>
|
||||||
|
<PageToolbarButton
|
||||||
|
label={
|
||||||
|
expandedState.allExpanded
|
||||||
|
? translate('CollapseAll')
|
||||||
|
: translate('ExpandAll')
|
||||||
|
}
|
||||||
|
iconName={expandIcon}
|
||||||
|
onPress={handleExpandAllPress}
|
||||||
|
/>
|
||||||
|
</PageToolbarSection>
|
||||||
|
</PageToolbar>
|
||||||
|
|
||||||
|
<PageContentBody innerClassName={styles.innerContentBody}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div
|
||||||
|
className={styles.backdrop}
|
||||||
|
style={
|
||||||
|
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.backdropOverlay} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
<SeriesPoster
|
||||||
|
className={styles.poster}
|
||||||
|
images={images}
|
||||||
|
size={500}
|
||||||
|
lazy={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.titleRow}>
|
||||||
|
<div className={styles.titleContainer}>
|
||||||
|
<div className={styles.toggleMonitoredContainer}>
|
||||||
|
<MonitorToggleButton
|
||||||
|
className={styles.monitorToggleButton}
|
||||||
|
monitored={monitored}
|
||||||
|
isSaving={isSaving}
|
||||||
|
size={40}
|
||||||
|
onPress={handleMonitorTogglePress}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.seriesNavigationButtons}>
|
<div className={styles.title}>{title}</div>
|
||||||
{previousSeries ? (
|
|
||||||
<IconButton
|
|
||||||
className={styles.seriesNavigationButton}
|
|
||||||
name={icons.ARROW_LEFT}
|
|
||||||
size={30}
|
|
||||||
title={translate('SeriesDetailsGoTo', {
|
|
||||||
title: previousSeries.title,
|
|
||||||
})}
|
|
||||||
to={`/series/${previousSeries.titleSlug}`}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{nextSeries ? (
|
{alternateTitles.length ? (
|
||||||
<IconButton
|
<div className={styles.alternateTitlesIconContainer}>
|
||||||
className={styles.seriesNavigationButton}
|
<Popover
|
||||||
name={icons.ARROW_RIGHT}
|
anchor={
|
||||||
size={30}
|
<Icon name={icons.ALTERNATE_TITLES} size={20} />
|
||||||
title={translate('SeriesDetailsGoTo', {
|
}
|
||||||
title: nextSeries.title,
|
title={translate('AlternateTitles')}
|
||||||
})}
|
body={
|
||||||
to={`/series/${nextSeries.titleSlug}`}
|
<SeriesAlternateTitles
|
||||||
/>
|
alternateTitles={alternateTitles}
|
||||||
) : null}
|
/>
|
||||||
</div>
|
}
|
||||||
</div>
|
position={tooltipPositions.BOTTOM}
|
||||||
|
/>
|
||||||
<div className={styles.details}>
|
|
||||||
<div>
|
|
||||||
{runtime ? (
|
|
||||||
<span className={styles.runtime}>
|
|
||||||
{translate('SeriesDetailsRuntime', { runtime })}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{ratings.value ? (
|
|
||||||
<HeartRating
|
|
||||||
rating={ratings.value}
|
|
||||||
votes={ratings.votes}
|
|
||||||
iconSize={20}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<SeriesGenres className={styles.genres} genres={genres} />
|
|
||||||
|
|
||||||
<span>{runningYears}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
|
||||||
<div>
|
|
||||||
<Icon name={icons.FOLDER} size={17} />
|
|
||||||
<span className={styles.path}>{path}</span>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
anchor={
|
|
||||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
|
||||||
<div>
|
|
||||||
<Icon name={icons.DRIVE} size={17} />
|
|
||||||
|
|
||||||
<span className={styles.sizeOnDisk}>
|
|
||||||
{formatBytes(sizeOnDisk)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Label>
|
) : null}
|
||||||
}
|
|
||||||
tooltip={<span>{episodeFilesCountMessage}</span>}
|
|
||||||
kind={kinds.INVERSE}
|
|
||||||
position={tooltipPositions.BOTTOM}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Label
|
|
||||||
className={styles.detailsLabel}
|
|
||||||
title={translate('QualityProfile')}
|
|
||||||
size={sizes.LARGE}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Icon name={icons.PROFILE} size={17} />
|
|
||||||
<span className={styles.qualityProfileName}>
|
|
||||||
<QualityProfileName qualityProfileId={qualityProfileId} />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
<div className={styles.seriesNavigationButtons}>
|
||||||
<div>
|
{previousSeries ? (
|
||||||
<Icon
|
<IconButton
|
||||||
name={monitored ? icons.MONITORED : icons.UNMONITORED}
|
className={styles.seriesNavigationButton}
|
||||||
size={17}
|
name={icons.ARROW_LEFT}
|
||||||
/>
|
size={30}
|
||||||
<span className={styles.qualityProfileName}>
|
title={translate('SeriesDetailsGoTo', {
|
||||||
{monitored
|
title: previousSeries.title,
|
||||||
? translate('Monitored')
|
})}
|
||||||
: translate('Unmonitored')}
|
to={`/series/${previousSeries.titleSlug}`}
|
||||||
</span>
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{nextSeries ? (
|
||||||
|
<IconButton
|
||||||
|
className={styles.seriesNavigationButton}
|
||||||
|
name={icons.ARROW_RIGHT}
|
||||||
|
size={30}
|
||||||
|
title={translate('SeriesDetailsGoTo', {
|
||||||
|
title: nextSeries.title,
|
||||||
|
})}
|
||||||
|
to={`/series/${nextSeries.titleSlug}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Label>
|
</div>
|
||||||
|
|
||||||
<Label
|
<div className={styles.details}>
|
||||||
className={styles.detailsLabel}
|
|
||||||
title={statusDetails.message}
|
|
||||||
size={sizes.LARGE}
|
|
||||||
kind={status === 'deleted' ? kinds.INVERSE : undefined}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<Icon name={statusDetails.icon} size={17} />
|
{runtime ? (
|
||||||
<span className={styles.statusName}>
|
<span className={styles.runtime}>
|
||||||
{statusDetails.title}
|
{translate('SeriesDetailsRuntime', { runtime })}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
{originalLanguage?.name ? (
|
|
||||||
<Label
|
|
||||||
className={styles.detailsLabel}
|
|
||||||
title={translate('OriginalLanguage')}
|
|
||||||
size={sizes.LARGE}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Icon name={icons.LANGUAGE} size={17} />
|
|
||||||
<span className={styles.originalLanguageName}>
|
|
||||||
{originalLanguage.name}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
) : null}
|
||||||
</Label>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{network ? (
|
{ratings.value ? (
|
||||||
<Label
|
<HeartRating
|
||||||
className={styles.detailsLabel}
|
rating={ratings.value}
|
||||||
title={translate('Network')}
|
votes={ratings.votes}
|
||||||
size={sizes.LARGE}
|
iconSize={20}
|
||||||
>
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SeriesGenres className={styles.genres} genres={genres} />
|
||||||
|
|
||||||
|
<span>{runningYears}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||||
<div>
|
<div>
|
||||||
<Icon name={icons.NETWORK} size={17} />
|
<Icon name={icons.FOLDER} size={17} />
|
||||||
<span className={styles.network}>{network}</span>
|
<span className={styles.path}>{path}</span>
|
||||||
</div>
|
</div>
|
||||||
</Label>
|
</Label>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
anchor={
|
|
||||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
|
||||||
<div>
|
|
||||||
<Icon name={icons.EXTERNAL_LINK} size={17} />
|
|
||||||
<span className={styles.links}>
|
|
||||||
{translate('Links')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
}
|
|
||||||
tooltip={
|
|
||||||
<SeriesDetailsLinks
|
|
||||||
tvdbId={tvdbId}
|
|
||||||
tvMazeId={tvMazeId}
|
|
||||||
imdbId={imdbId}
|
|
||||||
tmdbId={tmdbId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
kind={kinds.INVERSE}
|
|
||||||
position={tooltipPositions.BOTTOM}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{tags.length ? (
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
anchor={
|
anchor={
|
||||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||||
<Icon name={icons.TAGS} size={17} />
|
<div>
|
||||||
|
<Icon name={icons.DRIVE} size={17} />
|
||||||
|
|
||||||
<span className={styles.tags}>{translate('Tags')}</span>
|
<span className={styles.sizeOnDisk}>
|
||||||
|
{formatBytes(sizeOnDisk)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</Label>
|
</Label>
|
||||||
}
|
}
|
||||||
tooltip={<SeriesTags seriesId={seriesId} />}
|
tooltip={<span>{episodeFilesCountMessage}</span>}
|
||||||
kind={kinds.INVERSE}
|
kind={kinds.INVERSE}
|
||||||
position={tooltipPositions.BOTTOM}
|
position={tooltipPositions.BOTTOM}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<SeriesProgressLabel
|
<Label
|
||||||
className={styles.seriesProgressLabel}
|
className={styles.detailsLabel}
|
||||||
seriesId={seriesId}
|
title={translate('QualityProfile')}
|
||||||
monitored={monitored}
|
size={sizes.LARGE}
|
||||||
episodeCount={episodeCount}
|
>
|
||||||
episodeFileCount={episodeFileCount}
|
<div>
|
||||||
/>
|
<Icon name={icons.PROFILE} size={17} />
|
||||||
|
<span className={styles.qualityProfileName}>
|
||||||
|
<QualityProfileName
|
||||||
|
qualityProfileId={qualityProfileId}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||||
|
<div>
|
||||||
|
<Icon
|
||||||
|
name={monitored ? icons.MONITORED : icons.UNMONITORED}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
<span className={styles.qualityProfileName}>
|
||||||
|
{monitored
|
||||||
|
? translate('Monitored')
|
||||||
|
: translate('Unmonitored')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
className={styles.detailsLabel}
|
||||||
|
title={statusDetails.message}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
kind={status === 'deleted' ? kinds.INVERSE : undefined}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Icon name={statusDetails.icon} size={17} />
|
||||||
|
<span className={styles.statusName}>
|
||||||
|
{statusDetails.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{originalLanguage?.name ? (
|
||||||
|
<Label
|
||||||
|
className={styles.detailsLabel}
|
||||||
|
title={translate('OriginalLanguage')}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Icon name={icons.LANGUAGE} size={17} />
|
||||||
|
<span className={styles.originalLanguageName}>
|
||||||
|
{originalLanguage.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{network ? (
|
||||||
|
<Label
|
||||||
|
className={styles.detailsLabel}
|
||||||
|
title={translate('Network')}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Icon name={icons.NETWORK} size={17} />
|
||||||
|
<span className={styles.network}>{network}</span>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
anchor={
|
||||||
|
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||||
|
<div>
|
||||||
|
<Icon name={icons.EXTERNAL_LINK} size={17} />
|
||||||
|
<span className={styles.links}>
|
||||||
|
{translate('Links')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
}
|
||||||
|
tooltip={
|
||||||
|
<SeriesDetailsLinks
|
||||||
|
tvdbId={tvdbId}
|
||||||
|
tvMazeId={tvMazeId}
|
||||||
|
imdbId={imdbId}
|
||||||
|
tmdbId={tmdbId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
kind={kinds.INVERSE}
|
||||||
|
position={tooltipPositions.BOTTOM}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tags.length ? (
|
||||||
|
<Tooltip
|
||||||
|
anchor={
|
||||||
|
<Label
|
||||||
|
className={styles.detailsLabel}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
>
|
||||||
|
<Icon name={icons.TAGS} size={17} />
|
||||||
|
|
||||||
|
<span className={styles.tags}>
|
||||||
|
{translate('Tags')}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
}
|
||||||
|
tooltip={<SeriesTags seriesId={seriesId} />}
|
||||||
|
kind={kinds.INVERSE}
|
||||||
|
position={tooltipPositions.BOTTOM}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SeriesProgressLabel
|
||||||
|
className={styles.seriesProgressLabel}
|
||||||
|
seriesId={seriesId}
|
||||||
|
monitored={monitored}
|
||||||
|
episodeCount={episodeCount}
|
||||||
|
episodeFileCount={episodeFileCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.overview}>{overview}</div>
|
||||||
|
|
||||||
|
<MetadataAttribution />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.overview}>{overview}</div>
|
|
||||||
|
|
||||||
<MetadataAttribution />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.contentContainer}>
|
<div className={styles.contentContainer}>
|
||||||
{!isPopulated && !episodesError && !episodeFilesError ? (
|
{!isPopulated && !episodesError && !episodeFilesError ? (
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isFetching && episodesError ? (
|
{!isFetching && episodesError ? (
|
||||||
<Alert kind={kinds.DANGER}>{translate('EpisodesLoadError')}</Alert>
|
<Alert kind={kinds.DANGER}>
|
||||||
) : null}
|
{translate('EpisodesLoadError')}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!isFetching && episodeFilesError ? (
|
{!isFetching && episodeFilesError ? (
|
||||||
<Alert kind={kinds.DANGER}>
|
<Alert kind={kinds.DANGER}>
|
||||||
{translate('EpisodeFilesLoadError')}
|
{translate('EpisodeFilesLoadError')}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isPopulated && !!seasons.length ? (
|
{isPopulated && !!seasons.length ? (
|
||||||
<div>
|
<div>
|
||||||
{seasons
|
{seasons
|
||||||
.slice(0)
|
.slice(0)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((season) => {
|
.map((season) => {
|
||||||
return (
|
return (
|
||||||
<SeriesDetailsSeason
|
<SeriesDetailsSeason
|
||||||
key={season.seasonNumber}
|
key={season.seasonNumber}
|
||||||
seriesId={seriesId}
|
seriesId={seriesId}
|
||||||
{...season}
|
{...season}
|
||||||
isExpanded={expandedState.seasons[season.seasonNumber]}
|
isExpanded={expandedState.seasons[season.seasonNumber]}
|
||||||
onExpandPress={handleExpandPress}
|
onExpandPress={handleExpandPress}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isPopulated && !seasons.length ? (
|
{isPopulated && !seasons.length ? (
|
||||||
<Alert kind={kinds.WARNING}>
|
<Alert kind={kinds.WARNING}>
|
||||||
{translate('NoEpisodeInformation')}
|
{translate('NoEpisodeInformation')}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OrganizePreviewModal
|
<OrganizePreviewModal
|
||||||
isOpen={isOrganizeModalOpen}
|
isOpen={isOrganizeModalOpen}
|
||||||
seriesId={seriesId}
|
seriesId={seriesId}
|
||||||
onModalClose={handleOrganizeModalClose}
|
onModalClose={handleOrganizeModalClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InteractiveImportModal
|
<InteractiveImportModal
|
||||||
isOpen={isManageEpisodesOpen}
|
isOpen={isManageEpisodesOpen}
|
||||||
seriesId={seriesId}
|
seriesId={seriesId}
|
||||||
title={title}
|
title={title}
|
||||||
folder={path}
|
folder={path}
|
||||||
initialSortKey="relativePath"
|
initialSortKey="relativePath"
|
||||||
initialSortDirection={sortDirections.DESCENDING}
|
initialSortDirection={sortDirections.DESCENDING}
|
||||||
showSeries={false}
|
showSeries={false}
|
||||||
allowSeriesChange={false}
|
allowSeriesChange={false}
|
||||||
showDelete={true}
|
showDelete={true}
|
||||||
showImportMode={false}
|
showImportMode={false}
|
||||||
modalTitle={translate('ManageEpisodes')}
|
modalTitle={translate('ManageEpisodes')}
|
||||||
onModalClose={handleManageEpisodesModalClose}
|
onModalClose={handleManageEpisodesModalClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SeriesHistoryModal
|
<SeriesHistoryModal
|
||||||
isOpen={isSeriesHistoryModalOpen}
|
isOpen={isSeriesHistoryModalOpen}
|
||||||
seriesId={seriesId}
|
seriesId={seriesId}
|
||||||
onModalClose={handleSeriesHistoryModalClose}
|
onModalClose={handleSeriesHistoryModalClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EditSeriesModal
|
<EditSeriesModal
|
||||||
isOpen={isEditSeriesModalOpen}
|
isOpen={isEditSeriesModalOpen}
|
||||||
seriesId={seriesId}
|
seriesId={seriesId}
|
||||||
onModalClose={handleEditSeriesModalClose}
|
onModalClose={handleEditSeriesModalClose}
|
||||||
onDeleteSeriesPress={handleDeleteSeriesPress}
|
onDeleteSeriesPress={handleDeleteSeriesPress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteSeriesModal
|
<DeleteSeriesModal
|
||||||
isOpen={isDeleteSeriesModalOpen}
|
isOpen={isDeleteSeriesModalOpen}
|
||||||
seriesId={seriesId}
|
seriesId={seriesId}
|
||||||
onModalClose={handleDeleteSeriesModalClose}
|
onModalClose={handleDeleteSeriesModalClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MonitoringOptionsModal
|
<MonitoringOptionsModal
|
||||||
isOpen={isMonitorOptionsModalOpen}
|
isOpen={isMonitorOptionsModalOpen}
|
||||||
seriesId={seriesId}
|
seriesId={seriesId}
|
||||||
onModalClose={handleMonitorOptionsClose}
|
onModalClose={handleMonitorOptionsClose}
|
||||||
/>
|
/>
|
||||||
</PageContentBody>
|
</PageContentBody>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
</QueueDetailsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import Label from 'Components/Label';
|
import Label from 'Components/Label';
|
||||||
import { kinds, sizes } from 'Helpers/Props';
|
import { kinds, sizes } from 'Helpers/Props';
|
||||||
import createSeriesQueueItemsDetailsSelector, {
|
|
||||||
SeriesQueueDetails,
|
|
||||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
|
||||||
|
|
||||||
function getEpisodeCountKind(
|
function getEpisodeCountKind(
|
||||||
monitored: boolean,
|
monitored: boolean,
|
||||||
@@ -42,9 +39,7 @@ function SeriesProgressLabel({
|
|||||||
episodeCount,
|
episodeCount,
|
||||||
episodeFileCount,
|
episodeFileCount,
|
||||||
}: SeriesProgressLabelProps) {
|
}: SeriesProgressLabelProps) {
|
||||||
const queueDetails: SeriesQueueDetails = useSelector(
|
const queueDetails = useQueueDetailsForSeries(seriesId);
|
||||||
createSeriesQueueItemsDetailsSelector(seriesId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
||||||
const text = newDownloads
|
const text = newDownloads
|
||||||
|
|||||||
@@ -144,7 +144,10 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
|
|||||||
<div className={styles.poster}>
|
<div className={styles.poster}>
|
||||||
<div className={styles.posterContainer}>
|
<div className={styles.posterContainer}>
|
||||||
{isSelectMode ? (
|
{isSelectMode ? (
|
||||||
<SeriesIndexPosterSelect seriesId={seriesId} />
|
<SeriesIndexPosterSelect
|
||||||
|
seriesId={seriesId}
|
||||||
|
titleSlug={titleSlug}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{status === 'ended' ? (
|
{status === 'ended' ? (
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.posterContainer} title={title}>
|
<div className={styles.posterContainer} title={title}>
|
||||||
{isSelectMode ? <SeriesIndexPosterSelect seriesId={seriesId} /> : null}
|
{isSelectMode ? (
|
||||||
|
<SeriesIndexPosterSelect seriesId={seriesId} titleSlug={titleSlug} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Label className={styles.controls}>
|
<Label className={styles.controls}>
|
||||||
<SpinnerIconButton
|
<SpinnerIconButton
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import ProgressBar from 'Components/ProgressBar';
|
import ProgressBar from 'Components/ProgressBar';
|
||||||
import { sizes } from 'Helpers/Props';
|
import { sizes } from 'Helpers/Props';
|
||||||
import createSeriesQueueItemsDetailsSelector, {
|
|
||||||
SeriesQueueDetails,
|
|
||||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
|
||||||
import { SeriesStatus } from 'Series/Series';
|
import { SeriesStatus } from 'Series/Series';
|
||||||
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
|
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
@@ -37,9 +34,7 @@ function SeriesIndexProgressBar(props: SeriesIndexProgressBarProps) {
|
|||||||
isStandalone,
|
isStandalone,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const queueDetails: SeriesQueueDetails = useSelector(
|
const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
|
||||||
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
||||||
const progress = episodeCount ? (episodeFileCount / episodeCount) * 100 : 100;
|
const progress = episodeCount ? (episodeFileCount / episodeCount) * 100 : 100;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
width: 36px;
|
width: 100%;
|
||||||
height: 36px;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkContainer {
|
.checkContainer {
|
||||||
|
|||||||
@@ -7,15 +7,23 @@ import styles from './SeriesIndexPosterSelect.css';
|
|||||||
|
|
||||||
interface SeriesIndexPosterSelectProps {
|
interface SeriesIndexPosterSelectProps {
|
||||||
seriesId: number;
|
seriesId: number;
|
||||||
|
titleSlug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) {
|
function SeriesIndexPosterSelect({
|
||||||
const { seriesId } = props;
|
seriesId,
|
||||||
|
titleSlug,
|
||||||
|
}: SeriesIndexPosterSelectProps) {
|
||||||
const [selectState, selectDispatch] = useSelect();
|
const [selectState, selectDispatch] = useSelect();
|
||||||
const isSelected = selectState.selectedState[seriesId];
|
const isSelected = selectState.selectedState[seriesId];
|
||||||
|
|
||||||
const onSelectPress = useCallback(
|
const onSelectPress = useCallback(
|
||||||
(event: SyntheticEvent<HTMLElement, PointerEvent>) => {
|
(event: SyntheticEvent<HTMLElement, PointerEvent>) => {
|
||||||
|
if (event.nativeEvent.ctrlKey || event.nativeEvent.metaKey) {
|
||||||
|
window.open(`${window.Sonarr.urlBase}/series/${titleSlug}`, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const shiftKey = event.nativeEvent.shiftKey;
|
const shiftKey = event.nativeEvent.shiftKey;
|
||||||
|
|
||||||
selectDispatch({
|
selectDispatch({
|
||||||
@@ -25,7 +33,7 @@ function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) {
|
|||||||
shiftKey,
|
shiftKey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[seriesId, isSelected, selectDispatch]
|
[seriesId, titleSlug, isSelected, selectDispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import { SelectProvider } from 'App/SelectContext';
|
import { SelectProvider } from 'App/SelectContext';
|
||||||
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
|
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
|
||||||
import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState';
|
import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState';
|
||||||
@@ -26,7 +27,6 @@ import { DESCENDING } from 'Helpers/Props/sortDirections';
|
|||||||
import ParseToolbarButton from 'Parse/ParseToolbarButton';
|
import ParseToolbarButton from 'Parse/ParseToolbarButton';
|
||||||
import NoSeries from 'Series/NoSeries';
|
import NoSeries from 'Series/NoSeries';
|
||||||
import { executeCommand } from 'Store/Actions/commandActions';
|
import { executeCommand } from 'Store/Actions/commandActions';
|
||||||
import { fetchQueueDetails } from 'Store/Actions/queueActions';
|
|
||||||
import { fetchSeries } from 'Store/Actions/seriesActions';
|
import { fetchSeries } from 'Store/Actions/seriesActions';
|
||||||
import {
|
import {
|
||||||
setSeriesFilter,
|
setSeriesFilter,
|
||||||
@@ -104,7 +104,6 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchSeries());
|
dispatch(fetchSeries());
|
||||||
dispatch(fetchQueueDetails({ all: true }));
|
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const onRssSyncPress = useCallback(() => {
|
const onRssSyncPress = useCallback(() => {
|
||||||
@@ -217,155 +216,159 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
|
|||||||
const hasNoSeries = !totalItems;
|
const hasNoSeries = !totalItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectProvider items={items}>
|
<QueueDetailsProvider all={true}>
|
||||||
<PageContent>
|
<SelectProvider items={items}>
|
||||||
<PageToolbar>
|
<PageContent>
|
||||||
<PageToolbarSection>
|
<PageToolbar>
|
||||||
<SeriesIndexRefreshSeriesButton
|
<PageToolbarSection>
|
||||||
isSelectMode={isSelectMode}
|
<SeriesIndexRefreshSeriesButton
|
||||||
selectedFilterKey={selectedFilterKey}
|
isSelectMode={isSelectMode}
|
||||||
/>
|
selectedFilterKey={selectedFilterKey}
|
||||||
|
/>
|
||||||
|
|
||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
label={translate('RssSync')}
|
label={translate('RssSync')}
|
||||||
iconName={icons.RSS}
|
iconName={icons.RSS}
|
||||||
isSpinning={isRssSyncExecuting}
|
isSpinning={isRssSyncExecuting}
|
||||||
isDisabled={hasNoSeries}
|
isDisabled={hasNoSeries}
|
||||||
onPress={onRssSyncPress}
|
onPress={onRssSyncPress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PageToolbarSeparator />
|
<PageToolbarSeparator />
|
||||||
|
|
||||||
<SeriesIndexSelectModeButton
|
<SeriesIndexSelectModeButton
|
||||||
label={
|
label={
|
||||||
isSelectMode
|
isSelectMode
|
||||||
? translate('StopSelecting')
|
? translate('StopSelecting')
|
||||||
: translate('SelectSeries')
|
: translate('SelectSeries')
|
||||||
}
|
}
|
||||||
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
|
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
|
||||||
isSelectMode={isSelectMode}
|
isSelectMode={isSelectMode}
|
||||||
overflowComponent={SeriesIndexSelectModeMenuItem}
|
overflowComponent={SeriesIndexSelectModeMenuItem}
|
||||||
onPress={onSelectModePress}
|
onPress={onSelectModePress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SeriesIndexSelectAllButton
|
<SeriesIndexSelectAllButton
|
||||||
label="SelectAll"
|
label="SelectAll"
|
||||||
isSelectMode={isSelectMode}
|
isSelectMode={isSelectMode}
|
||||||
overflowComponent={SeriesIndexSelectAllMenuItem}
|
overflowComponent={SeriesIndexSelectAllMenuItem}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PageToolbarSeparator />
|
<PageToolbarSeparator />
|
||||||
<ParseToolbarButton />
|
<ParseToolbarButton />
|
||||||
</PageToolbarSection>
|
</PageToolbarSection>
|
||||||
|
|
||||||
<PageToolbarSection
|
<PageToolbarSection
|
||||||
alignContent={align.RIGHT}
|
alignContent={align.RIGHT}
|
||||||
collapseButtons={false}
|
collapseButtons={false}
|
||||||
>
|
>
|
||||||
{view === 'table' ? (
|
{view === 'table' ? (
|
||||||
<TableOptionsModalWrapper
|
<TableOptionsModalWrapper
|
||||||
columns={columns}
|
columns={columns}
|
||||||
optionsComponent={SeriesIndexTableOptions}
|
optionsComponent={SeriesIndexTableOptions}
|
||||||
onTableOptionChange={onTableOptionChange}
|
onTableOptionChange={onTableOptionChange}
|
||||||
>
|
>
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('Options')}
|
||||||
|
iconName={icons.TABLE}
|
||||||
|
/>
|
||||||
|
</TableOptionsModalWrapper>
|
||||||
|
) : (
|
||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
label={translate('Options')}
|
label={translate('Options')}
|
||||||
iconName={icons.TABLE}
|
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
|
||||||
|
isDisabled={hasNoSeries}
|
||||||
|
onPress={onOptionsPress}
|
||||||
/>
|
/>
|
||||||
</TableOptionsModalWrapper>
|
)}
|
||||||
) : (
|
|
||||||
<PageToolbarButton
|
<PageToolbarSeparator />
|
||||||
label={translate('Options')}
|
|
||||||
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
|
<SeriesIndexViewMenu
|
||||||
|
view={view}
|
||||||
isDisabled={hasNoSeries}
|
isDisabled={hasNoSeries}
|
||||||
onPress={onOptionsPress}
|
onViewSelect={onViewSelect}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<PageToolbarSeparator />
|
<SeriesIndexSortMenu
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
isDisabled={hasNoSeries}
|
||||||
|
onSortSelect={onSortSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
<SeriesIndexViewMenu
|
<SeriesIndexFilterMenu
|
||||||
view={view}
|
selectedFilterKey={selectedFilterKey}
|
||||||
isDisabled={hasNoSeries}
|
filters={filters}
|
||||||
onViewSelect={onViewSelect}
|
customFilters={customFilters}
|
||||||
/>
|
isDisabled={hasNoSeries}
|
||||||
|
onFilterSelect={onFilterSelect}
|
||||||
|
/>
|
||||||
|
</PageToolbarSection>
|
||||||
|
</PageToolbar>
|
||||||
|
<div className={styles.pageContentBodyWrapper}>
|
||||||
|
<PageContentBody
|
||||||
|
ref={scrollerRef}
|
||||||
|
className={styles.contentBody}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
innerClassName={styles[`${view}InnerContentBody`]}
|
||||||
|
initialScrollTop={props.initialScrollTop}
|
||||||
|
onScroll={onScroll}
|
||||||
|
>
|
||||||
|
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||||
|
|
||||||
<SeriesIndexSortMenu
|
{!isFetching && !!error ? (
|
||||||
sortKey={sortKey}
|
<Alert kind={kinds.DANGER}>
|
||||||
sortDirection={sortDirection}
|
{translate('SeriesLoadError')}
|
||||||
isDisabled={hasNoSeries}
|
</Alert>
|
||||||
onSortSelect={onSortSelect}
|
) : null}
|
||||||
/>
|
|
||||||
|
|
||||||
<SeriesIndexFilterMenu
|
{isLoaded ? (
|
||||||
selectedFilterKey={selectedFilterKey}
|
<div className={styles.contentBodyContainer}>
|
||||||
filters={filters}
|
<ViewComponent
|
||||||
customFilters={customFilters}
|
scrollerRef={scrollerRef}
|
||||||
isDisabled={hasNoSeries}
|
items={items}
|
||||||
onFilterSelect={onFilterSelect}
|
sortKey={sortKey}
|
||||||
/>
|
sortDirection={sortDirection}
|
||||||
</PageToolbarSection>
|
jumpToCharacter={jumpToCharacter}
|
||||||
</PageToolbar>
|
isSelectMode={isSelectMode}
|
||||||
<div className={styles.pageContentBodyWrapper}>
|
isSmallScreen={isSmallScreen}
|
||||||
<PageContentBody
|
/>
|
||||||
ref={scrollerRef}
|
|
||||||
className={styles.contentBody}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
innerClassName={styles[`${view}InnerContentBody`]}
|
|
||||||
initialScrollTop={props.initialScrollTop}
|
|
||||||
onScroll={onScroll}
|
|
||||||
>
|
|
||||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
|
||||||
|
|
||||||
{!isFetching && !!error ? (
|
<SeriesIndexFooter />
|
||||||
<Alert kind={kinds.DANGER}>{translate('SeriesLoadError')}</Alert>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!error && isPopulated && !items.length ? (
|
||||||
|
<NoSeries totalItems={totalItems} />
|
||||||
|
) : null}
|
||||||
|
</PageContentBody>
|
||||||
|
{isLoaded && !!jumpBarItems.order.length ? (
|
||||||
|
<PageJumpBar
|
||||||
|
items={jumpBarItems}
|
||||||
|
onItemPress={onJumpBarItemPress}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoaded ? (
|
{isSelectMode ? <SeriesIndexSelectFooter /> : null}
|
||||||
<div className={styles.contentBodyContainer}>
|
|
||||||
<ViewComponent
|
|
||||||
scrollerRef={scrollerRef}
|
|
||||||
items={items}
|
|
||||||
sortKey={sortKey}
|
|
||||||
sortDirection={sortDirection}
|
|
||||||
jumpToCharacter={jumpToCharacter}
|
|
||||||
isSelectMode={isSelectMode}
|
|
||||||
isSmallScreen={isSmallScreen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SeriesIndexFooter />
|
{view === 'posters' ? (
|
||||||
</div>
|
<SeriesIndexPosterOptionsModal
|
||||||
) : null}
|
isOpen={isOptionsModalOpen}
|
||||||
|
onModalClose={onOptionsModalClose}
|
||||||
{!error && isPopulated && !items.length ? (
|
|
||||||
<NoSeries totalItems={totalItems} />
|
|
||||||
) : null}
|
|
||||||
</PageContentBody>
|
|
||||||
{isLoaded && !!jumpBarItems.order.length ? (
|
|
||||||
<PageJumpBar
|
|
||||||
items={jumpBarItems}
|
|
||||||
onItemPress={onJumpBarItemPress}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
{view === 'overview' ? (
|
||||||
|
<SeriesIndexOverviewOptionsModal
|
||||||
{isSelectMode ? <SeriesIndexSelectFooter /> : null}
|
isOpen={isOptionsModalOpen}
|
||||||
|
onModalClose={onOptionsModalClose}
|
||||||
{view === 'posters' ? (
|
/>
|
||||||
<SeriesIndexPosterOptionsModal
|
) : null}
|
||||||
isOpen={isOptionsModalOpen}
|
</PageContent>
|
||||||
onModalClose={onOptionsModalClose}
|
</SelectProvider>
|
||||||
/>
|
</QueueDetailsProvider>
|
||||||
) : null}
|
|
||||||
{view === 'overview' ? (
|
|
||||||
<SeriesIndexOverviewOptionsModal
|
|
||||||
isOpen={isOptionsModalOpen}
|
|
||||||
onModalClose={onOptionsModalClose}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</PageContent>
|
|
||||||
</SelectProvider>
|
|
||||||
);
|
);
|
||||||
}, 'seriesIndex');
|
}, 'seriesIndex');
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { createSelector } from 'reselect';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
|
|
||||||
export interface SeriesQueueDetails {
|
|
||||||
count: number;
|
|
||||||
episodesWithFiles: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSeriesQueueDetailsSelector(
|
|
||||||
seriesId: number,
|
|
||||||
seasonNumber?: number
|
|
||||||
) {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.queue.details.items,
|
|
||||||
(queueItems) => {
|
|
||||||
return queueItems.reduce(
|
|
||||||
(acc: SeriesQueueDetails, item) => {
|
|
||||||
if (
|
|
||||||
item.trackedDownloadState === 'imported' ||
|
|
||||||
item.seriesId !== seriesId
|
|
||||||
) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.count++;
|
|
||||||
|
|
||||||
if (item.episodeHasFile) {
|
|
||||||
acc.episodesWithFiles++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
count: 0,
|
|
||||||
episodesWithFiles: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createSeriesQueueDetailsSelector;
|
|
||||||
@@ -156,6 +156,7 @@ function GeneralSettings() {
|
|||||||
enableSsl={settings.enableSsl}
|
enableSsl={settings.enableSsl}
|
||||||
sslPort={settings.sslPort}
|
sslPort={settings.sslPort}
|
||||||
sslCertPath={settings.sslCertPath}
|
sslCertPath={settings.sslCertPath}
|
||||||
|
sslKeyPath={settings.sslKeyPath}
|
||||||
sslCertPassword={settings.sslCertPassword}
|
sslCertPassword={settings.sslCertPassword}
|
||||||
launchBrowser={settings.launchBrowser}
|
launchBrowser={settings.launchBrowser}
|
||||||
onInputChange={handleInputChange}
|
onInputChange={handleInputChange}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface HostSettingsProps {
|
|||||||
applicationUrl: PendingSection<General>['applicationUrl'];
|
applicationUrl: PendingSection<General>['applicationUrl'];
|
||||||
enableSsl: PendingSection<General>['enableSsl'];
|
enableSsl: PendingSection<General>['enableSsl'];
|
||||||
sslPort: PendingSection<General>['sslPort'];
|
sslPort: PendingSection<General>['sslPort'];
|
||||||
|
sslKeyPath: PendingSection<General>['sslKeyPath'];
|
||||||
sslCertPath: PendingSection<General>['sslCertPath'];
|
sslCertPath: PendingSection<General>['sslCertPath'];
|
||||||
sslCertPassword: PendingSection<General>['sslCertPassword'];
|
sslCertPassword: PendingSection<General>['sslCertPassword'];
|
||||||
launchBrowser: PendingSection<General>['launchBrowser'];
|
launchBrowser: PendingSection<General>['launchBrowser'];
|
||||||
@@ -34,6 +35,7 @@ function HostSettings({
|
|||||||
enableSsl,
|
enableSsl,
|
||||||
sslPort,
|
sslPort,
|
||||||
sslCertPath,
|
sslCertPath,
|
||||||
|
sslKeyPath,
|
||||||
sslCertPassword,
|
sslCertPassword,
|
||||||
launchBrowser,
|
launchBrowser,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
@@ -142,36 +144,49 @@ function HostSettings({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{enableSsl.value ? (
|
{enableSsl.value ? (
|
||||||
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
|
<>
|
||||||
<FormLabel>{translate('SslCertPath')}</FormLabel>
|
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
|
||||||
|
<FormLabel>{translate('SslCertPath')}</FormLabel>
|
||||||
|
|
||||||
<FormInputGroup
|
<FormInputGroup
|
||||||
type={inputTypes.TEXT}
|
type={inputTypes.TEXT}
|
||||||
name="sslCertPath"
|
name="sslCertPath"
|
||||||
helpText={translate('SslCertPathHelpText')}
|
helpText={translate('SslCertPathHelpText')}
|
||||||
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
|
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
{...sslCertPath}
|
{...sslCertPath}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
|
||||||
|
<FormLabel>{translate('SslKeyPath')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.TEXT}
|
||||||
|
name="sslKeyPath"
|
||||||
|
helpText={translate('SslKeyPathHelpText')}
|
||||||
|
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...sslKeyPath}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
|
||||||
|
<FormLabel>{translate('SslCertPassword')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.PASSWORD}
|
||||||
|
name="sslCertPassword"
|
||||||
|
helpText={translate('SslCertPasswordHelpText')}
|
||||||
|
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...sslCertPassword}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{enableSsl.value ? (
|
{isWindowsService ? null : (
|
||||||
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
|
|
||||||
<FormLabel>{translate('SslCertPassword')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.PASSWORD}
|
|
||||||
name="sslCertPassword"
|
|
||||||
helpText={translate('SslCertPasswordHelpText')}
|
|
||||||
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
|
|
||||||
onChange={onInputChange}
|
|
||||||
{...sslCertPassword}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isWindowsService ? (
|
|
||||||
<FormGroup size={sizes.MEDIUM}>
|
<FormGroup size={sizes.MEDIUM}>
|
||||||
<FormLabel>{translate('OpenBrowserOnStart')}</FormLabel>
|
<FormLabel>{translate('OpenBrowserOnStart')}</FormLabel>
|
||||||
|
|
||||||
@@ -183,7 +198,7 @@ function HostSettings({
|
|||||||
{...launchBrowser}
|
{...launchBrowser}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
) : null}
|
)}
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,27 @@ const fileDateOptions: EnhancedSelectInputValue<string>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const seasonPackUpgradeOptions: EnhancedSelectInputValue<string>[] = [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
get value() {
|
||||||
|
return translate('All');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'threshold',
|
||||||
|
get value() {
|
||||||
|
return translate('Threshold');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'any',
|
||||||
|
get value() {
|
||||||
|
return translate('Any');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function MediaManagement() {
|
function MediaManagement() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const showAdvancedSettings = useShowAdvancedSettings();
|
const showAdvancedSettings = useShowAdvancedSettings();
|
||||||
@@ -379,6 +400,82 @@ function MediaManagement() {
|
|||||||
{...settings.userRejectedExtensions}
|
{...settings.userRejectedExtensions}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
{showAdvancedSettings && (
|
||||||
|
<>
|
||||||
|
<FormGroup
|
||||||
|
advancedSettings={showAdvancedSettings}
|
||||||
|
isAdvanced={true}
|
||||||
|
size={sizes.MEDIUM}
|
||||||
|
>
|
||||||
|
<FormLabel>
|
||||||
|
{translate('SeasonPackUpgradeAllowLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SELECT}
|
||||||
|
name="seasonPackUpgrade"
|
||||||
|
helpText={translate('SeasonPackUpgradeAllowHelpText')}
|
||||||
|
helpTextWarning={
|
||||||
|
settings.seasonPackUpgrade.value === 'any'
|
||||||
|
? translate('SeasonPackUpgradeAllowAnyWarning')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
values={seasonPackUpgradeOptions}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
{...settings.seasonPackUpgrade}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{settings.seasonPackUpgrade.value === 'threshold' && (
|
||||||
|
<FormGroup
|
||||||
|
advancedSettings={showAdvancedSettings}
|
||||||
|
isAdvanced={true}
|
||||||
|
size={sizes.MEDIUM}
|
||||||
|
>
|
||||||
|
<FormLabel>
|
||||||
|
{translate('SeasonPackUpgradeThresholdLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.FLOAT}
|
||||||
|
name="seasonPackUpgradeThreshold"
|
||||||
|
unit="%"
|
||||||
|
step={0.01}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
helpTexts={[
|
||||||
|
translate('SeasonPackUpgradeThresholdHelpText'),
|
||||||
|
translate(
|
||||||
|
'SeasonPackUpgradeThresholdHelpTextExample',
|
||||||
|
{
|
||||||
|
numberEpisodes: 2,
|
||||||
|
totalEpisodes: 8,
|
||||||
|
count: Math.ceil((100 * 2) / 8),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
translate(
|
||||||
|
'SeasonPackUpgradeThresholdHelpTextExample',
|
||||||
|
{
|
||||||
|
numberEpisodes: 3,
|
||||||
|
totalEpisodes: 12,
|
||||||
|
count: Math.ceil((100 * 3) / 12),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
translate(
|
||||||
|
'SeasonPackUpgradeThresholdHelpTextExample',
|
||||||
|
{
|
||||||
|
numberEpisodes: 6,
|
||||||
|
totalEpisodes: 24,
|
||||||
|
count: Math.ceil((100 * 6) / 24),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
{...settings.seasonPackUpgradeThreshold}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const newReleaseProfile: ReleaseProfile = {
|
|||||||
required: [],
|
required: [],
|
||||||
ignored: [],
|
ignored: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
|
excludedTags: [],
|
||||||
indexerId: 0,
|
indexerId: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,7 +77,8 @@ function EditReleaseProfileModalContent({
|
|||||||
const { item, isFetching, isSaving, error, saveError, ...otherProps } =
|
const { item, isFetching, isSaving, error, saveError, ...otherProps } =
|
||||||
useSelector(createReleaseProfileSelector(id));
|
useSelector(createReleaseProfileSelector(id));
|
||||||
|
|
||||||
const { name, enabled, required, ignored, tags, indexerId } = item;
|
const { name, enabled, required, ignored, tags, excludedTags, indexerId } =
|
||||||
|
item;
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const previousIsSaving = usePrevious(isSaving);
|
const previousIsSaving = usePrevious(isSaving);
|
||||||
@@ -202,6 +204,19 @@ function EditReleaseProfileModalContent({
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('ExcludedTags')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.TAG}
|
||||||
|
name="excludedTags"
|
||||||
|
helpText={translate('ReleaseProfileExcludedTagSeriesHelpText')}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
{...excludedTags}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
</Form>
|
</Form>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
|
|||||||
required = [],
|
required = [],
|
||||||
ignored = [],
|
ignored = [],
|
||||||
tags,
|
tags,
|
||||||
|
excludedTags,
|
||||||
indexerId = 0,
|
indexerId = 0,
|
||||||
tagList,
|
tagList,
|
||||||
indexerList,
|
indexerList,
|
||||||
@@ -92,6 +93,8 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
|
|||||||
|
|
||||||
<TagList tags={tags} tagList={tagList} />
|
<TagList tags={tags} tagList={tagList} />
|
||||||
|
|
||||||
|
<TagList tags={excludedTags} tagList={tagList} kind={kinds.DANGER} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{enabled ? null : (
|
{enabled ? null : (
|
||||||
<Label kind={kinds.DISABLED} outline={true}>
|
<Label kind={kinds.DISABLED} outline={true}>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export interface TagDetailsModalContentProps {
|
|||||||
delayProfileIds: number[];
|
delayProfileIds: number[];
|
||||||
importListIds: number[];
|
importListIds: number[];
|
||||||
notificationIds: number[];
|
notificationIds: number[];
|
||||||
restrictionIds: number[];
|
releaseProfileIds: number[];
|
||||||
indexerIds: number[];
|
indexerIds: number[];
|
||||||
downloadClientIds: number[];
|
downloadClientIds: number[];
|
||||||
autoTagIds: number[];
|
autoTagIds: number[];
|
||||||
@@ -76,7 +76,7 @@ function TagDetailsModalContent({
|
|||||||
delayProfileIds = [],
|
delayProfileIds = [],
|
||||||
importListIds = [],
|
importListIds = [],
|
||||||
notificationIds = [],
|
notificationIds = [],
|
||||||
restrictionIds = [],
|
releaseProfileIds = [],
|
||||||
indexerIds = [],
|
indexerIds = [],
|
||||||
downloadClientIds = [],
|
downloadClientIds = [],
|
||||||
autoTagIds = [],
|
autoTagIds = [],
|
||||||
@@ -109,7 +109,7 @@ function TagDetailsModalContent({
|
|||||||
|
|
||||||
const releaseProfiles = useSelector(
|
const releaseProfiles = useSelector(
|
||||||
createMatchingItemSelector(
|
createMatchingItemSelector(
|
||||||
restrictionIds,
|
releaseProfileIds,
|
||||||
(state: AppState) => state.settings.releaseProfiles.items
|
(state: AppState) => state.settings.releaseProfiles.items
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function Tag({ id, label }: TagProps) {
|
|||||||
importListIds = [],
|
importListIds = [],
|
||||||
notificationIds = [],
|
notificationIds = [],
|
||||||
restrictionIds = [],
|
restrictionIds = [],
|
||||||
|
excludedReleaseProfileIds = [],
|
||||||
indexerIds = [],
|
indexerIds = [],
|
||||||
downloadClientIds = [],
|
downloadClientIds = [],
|
||||||
autoTagIds = [],
|
autoTagIds = [],
|
||||||
@@ -35,12 +36,17 @@ function Tag({ id, label }: TagProps) {
|
|||||||
importListIds.length ||
|
importListIds.length ||
|
||||||
notificationIds.length ||
|
notificationIds.length ||
|
||||||
restrictionIds.length ||
|
restrictionIds.length ||
|
||||||
|
excludedReleaseProfileIds.length ||
|
||||||
indexerIds.length ||
|
indexerIds.length ||
|
||||||
downloadClientIds.length ||
|
downloadClientIds.length ||
|
||||||
autoTagIds.length ||
|
autoTagIds.length ||
|
||||||
seriesIds.length
|
seriesIds.length
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mergedReleaseProfileIds = Array.from(
|
||||||
|
new Set([...restrictionIds, ...excludedReleaseProfileIds]).values()
|
||||||
|
);
|
||||||
|
|
||||||
const handleShowDetailsPress = useCallback(() => {
|
const handleShowDetailsPress = useCallback(() => {
|
||||||
setIsDetailsModalOpen(true);
|
setIsDetailsModalOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -95,7 +101,7 @@ function Tag({ id, label }: TagProps) {
|
|||||||
<TagInUse
|
<TagInUse
|
||||||
label={translate('ReleaseProfile')}
|
label={translate('ReleaseProfile')}
|
||||||
labelPlural={translate('ReleaseProfiles')}
|
labelPlural={translate('ReleaseProfiles')}
|
||||||
count={restrictionIds.length}
|
count={mergedReleaseProfileIds.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TagInUse
|
<TagInUse
|
||||||
@@ -126,7 +132,7 @@ function Tag({ id, label }: TagProps) {
|
|||||||
delayProfileIds={delayProfileIds}
|
delayProfileIds={delayProfileIds}
|
||||||
importListIds={importListIds}
|
importListIds={importListIds}
|
||||||
notificationIds={notificationIds}
|
notificationIds={notificationIds}
|
||||||
restrictionIds={restrictionIds}
|
releaseProfileIds={mergedReleaseProfileIds}
|
||||||
indexerIds={indexerIds}
|
indexerIds={indexerIds}
|
||||||
downloadClientIds={downloadClientIds}
|
downloadClientIds={downloadClientIds}
|
||||||
autoTagIds={autoTagIds}
|
autoTagIds={autoTagIds}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
|
|||||||
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
||||||
import themes from 'Styles/Themes';
|
import themes from 'Styles/Themes';
|
||||||
import { InputChanged } from 'typings/inputs';
|
import { InputChanged } from 'typings/inputs';
|
||||||
|
import timeZoneOptions from 'Utilities/Date/timeZoneOptions';
|
||||||
import titleCase from 'Utilities/String/titleCase';
|
import titleCase from 'Utilities/String/titleCase';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
@@ -218,6 +219,18 @@ function UISettings() {
|
|||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('TimeZone')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SELECT}
|
||||||
|
name="timeZone"
|
||||||
|
values={timeZoneOptions}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
{...settings.timeZone}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormLabel>{translate('ShowRelativeDates')}</FormLabel>
|
<FormLabel>{translate('ShowRelativeDates')}</FormLabel>
|
||||||
<FormInputGroup
|
<FormInputGroup
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import updateSectionState from 'Utilities/State/updateSectionState';
|
|||||||
function createSetSettingValueReducer(section) {
|
function createSetSettingValueReducer(section) {
|
||||||
return (state, { payload }) => {
|
return (state, { payload }) => {
|
||||||
if (section === payload.section) {
|
if (section === payload.section) {
|
||||||
const { name, value } = payload;
|
const { name, value, isFloat } = payload;
|
||||||
const newState = getSectionState(state, section);
|
const newState = getSectionState(state, section);
|
||||||
newState.pendingChanges = Object.assign({}, newState.pendingChanges);
|
newState.pendingChanges = Object.assign({}, newState.pendingChanges);
|
||||||
|
|
||||||
@@ -15,7 +15,12 @@ function createSetSettingValueReducer(section) {
|
|||||||
let parsedValue = null;
|
let parsedValue = null;
|
||||||
|
|
||||||
if (_.isNumber(currentValue) && value != null) {
|
if (_.isNumber(currentValue) && value != null) {
|
||||||
parsedValue = parseInt(value);
|
// Use isFloat property to determine parsing method
|
||||||
|
if (isFloat) {
|
||||||
|
parsedValue = parseFloat(value);
|
||||||
|
} else {
|
||||||
|
parsedValue = parseInt(value);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
parsedValue = value;
|
parsedValue = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
import { createAction } from 'redux-actions';
|
|
||||||
import { batchActions } from 'redux-batched-actions';
|
|
||||||
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
|
|
||||||
import { createThunk, handleThunks } from 'Store/thunks';
|
|
||||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
|
||||||
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import { set, updateItem } from './baseActions';
|
|
||||||
import createHandleActions from './Creators/createHandleActions';
|
|
||||||
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
|
|
||||||
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
|
|
||||||
import createClearReducer from './Creators/Reducers/createClearReducer';
|
|
||||||
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
|
|
||||||
|
|
||||||
//
|
|
||||||
// Variables
|
|
||||||
|
|
||||||
export const section = 'blocklist';
|
|
||||||
|
|
||||||
//
|
|
||||||
// State
|
|
||||||
|
|
||||||
export const defaultState = {
|
|
||||||
isFetching: false,
|
|
||||||
isPopulated: false,
|
|
||||||
pageSize: 20,
|
|
||||||
sortKey: 'date',
|
|
||||||
sortDirection: sortDirections.DESCENDING,
|
|
||||||
error: null,
|
|
||||||
items: [],
|
|
||||||
isRemoving: false,
|
|
||||||
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
name: 'series.sortTitle',
|
|
||||||
label: () => translate('SeriesTitle'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'sourceTitle',
|
|
||||||
label: () => translate('SourceTitle'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'languages',
|
|
||||||
label: () => translate('Languages'),
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'quality',
|
|
||||||
label: () => translate('Quality'),
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'customFormats',
|
|
||||||
label: () => translate('Formats'),
|
|
||||||
isSortable: false,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'date',
|
|
||||||
label: () => translate('Date'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'indexer',
|
|
||||||
label: () => translate('Indexer'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'actions',
|
|
||||||
columnLabel: () => translate('Actions'),
|
|
||||||
isVisible: true,
|
|
||||||
isModifiable: false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
selectedFilterKey: 'all',
|
|
||||||
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'all',
|
|
||||||
label: () => translate('All'),
|
|
||||||
filters: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
filterBuilderProps: [
|
|
||||||
{
|
|
||||||
name: 'seriesIds',
|
|
||||||
label: () => translate('Series'),
|
|
||||||
type: filterBuilderTypes.EQUAL,
|
|
||||||
valueType: filterBuilderValueTypes.SERIES
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'protocols',
|
|
||||||
label: () => translate('Protocol'),
|
|
||||||
type: filterBuilderTypes.EQUAL,
|
|
||||||
valueType: filterBuilderValueTypes.PROTOCOL
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
export const persistState = [
|
|
||||||
'blocklist.pageSize',
|
|
||||||
'blocklist.sortKey',
|
|
||||||
'blocklist.sortDirection',
|
|
||||||
'blocklist.selectedFilterKey',
|
|
||||||
'blocklist.columns'
|
|
||||||
];
|
|
||||||
|
|
||||||
//
|
|
||||||
// Action Types
|
|
||||||
|
|
||||||
export const FETCH_BLOCKLIST = 'blocklist/fetchBlocklist';
|
|
||||||
export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage';
|
|
||||||
export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort';
|
|
||||||
export const SET_BLOCKLIST_FILTER = 'blocklist/setBlocklistFilter';
|
|
||||||
export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption';
|
|
||||||
export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem';
|
|
||||||
export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems';
|
|
||||||
export const CLEAR_BLOCKLIST = 'blocklist/clearBlocklist';
|
|
||||||
|
|
||||||
//
|
|
||||||
// Action Creators
|
|
||||||
|
|
||||||
export const fetchBlocklist = createThunk(FETCH_BLOCKLIST);
|
|
||||||
export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE);
|
|
||||||
export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT);
|
|
||||||
export const setBlocklistFilter = createThunk(SET_BLOCKLIST_FILTER);
|
|
||||||
export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION);
|
|
||||||
export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM);
|
|
||||||
export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS);
|
|
||||||
export const clearBlocklist = createAction(CLEAR_BLOCKLIST);
|
|
||||||
|
|
||||||
//
|
|
||||||
// Action Handlers
|
|
||||||
|
|
||||||
export const actionHandlers = handleThunks({
|
|
||||||
...createServerSideCollectionHandlers(
|
|
||||||
section,
|
|
||||||
'/blocklist',
|
|
||||||
fetchBlocklist,
|
|
||||||
{
|
|
||||||
[serverSideCollectionHandlers.FETCH]: FETCH_BLOCKLIST,
|
|
||||||
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLOCKLIST_PAGE,
|
|
||||||
[serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT,
|
|
||||||
[serverSideCollectionHandlers.FILTER]: SET_BLOCKLIST_FILTER
|
|
||||||
}),
|
|
||||||
|
|
||||||
[REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'),
|
|
||||||
|
|
||||||
[REMOVE_BLOCKLIST_ITEMS]: function(getState, payload, dispatch) {
|
|
||||||
const {
|
|
||||||
ids
|
|
||||||
} = payload;
|
|
||||||
|
|
||||||
dispatch(batchActions([
|
|
||||||
...ids.map((id) => {
|
|
||||||
return updateItem({
|
|
||||||
section,
|
|
||||||
id,
|
|
||||||
isRemoving: true
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
set({ section, isRemoving: true })
|
|
||||||
]));
|
|
||||||
|
|
||||||
const promise = createAjaxRequest({
|
|
||||||
url: '/blocklist/bulk',
|
|
||||||
method: 'DELETE',
|
|
||||||
dataType: 'json',
|
|
||||||
contentType: 'application/json',
|
|
||||||
data: JSON.stringify({ ids })
|
|
||||||
}).request;
|
|
||||||
|
|
||||||
promise.done((data) => {
|
|
||||||
// Don't use batchActions with thunks
|
|
||||||
dispatch(fetchBlocklist());
|
|
||||||
|
|
||||||
dispatch(set({ section, isRemoving: false }));
|
|
||||||
});
|
|
||||||
|
|
||||||
promise.fail((xhr) => {
|
|
||||||
dispatch(batchActions([
|
|
||||||
...ids.map((id) => {
|
|
||||||
return updateItem({
|
|
||||||
section,
|
|
||||||
id,
|
|
||||||
isRemoving: false
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
set({ section, isRemoving: false })
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//
|
|
||||||
// Reducers
|
|
||||||
|
|
||||||
export const reducers = createHandleActions({
|
|
||||||
|
|
||||||
[SET_BLOCKLIST_TABLE_OPTION]: createSetTableOptionReducer(section),
|
|
||||||
|
|
||||||
[CLEAR_BLOCKLIST]: createClearReducer(section, {
|
|
||||||
isFetching: false,
|
|
||||||
isPopulated: false,
|
|
||||||
error: null,
|
|
||||||
items: [],
|
|
||||||
totalPages: 0,
|
|
||||||
totalRecords: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
}, defaultState, section);
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { createAction } from 'redux-actions';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, icons, sortDirections } from 'Helpers/Props';
|
|
||||||
import { createThunk, handleThunks } from 'Store/thunks';
|
|
||||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
|
||||||
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import { updateItem } from './baseActions';
|
|
||||||
import createHandleActions from './Creators/createHandleActions';
|
|
||||||
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
|
|
||||||
import createClearReducer from './Creators/Reducers/createClearReducer';
|
|
||||||
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
|
|
||||||
|
|
||||||
//
|
|
||||||
// Variables
|
|
||||||
|
|
||||||
export const section = 'history';
|
|
||||||
|
|
||||||
//
|
|
||||||
// State
|
|
||||||
|
|
||||||
export const defaultState = {
|
|
||||||
isFetching: false,
|
|
||||||
isPopulated: false,
|
|
||||||
error: null,
|
|
||||||
pageSize: 20,
|
|
||||||
sortKey: 'date',
|
|
||||||
sortDirection: sortDirections.DESCENDING,
|
|
||||||
items: [],
|
|
||||||
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
name: 'eventType',
|
|
||||||
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',
|
|
||||||
columnLabel: () => translate('Details'),
|
|
||||||
isVisible: true,
|
|
||||||
isModifiable: false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
selectedFilterKey: 'all',
|
|
||||||
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'all',
|
|
||||||
label: () => translate('All'),
|
|
||||||
filters: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'grabbed',
|
|
||||||
label: () => translate('Grabbed'),
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'eventType',
|
|
||||||
value: '1',
|
|
||||||
type: filterTypes.EQUAL
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'imported',
|
|
||||||
label: () => translate('Imported'),
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'eventType',
|
|
||||||
value: '3',
|
|
||||||
type: filterTypes.EQUAL
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'failed',
|
|
||||||
label: () => translate('Failed'),
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'eventType',
|
|
||||||
value: '4',
|
|
||||||
type: filterTypes.EQUAL
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'deleted',
|
|
||||||
label: () => translate('Deleted'),
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'eventType',
|
|
||||||
value: '5',
|
|
||||||
type: filterTypes.EQUAL
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'renamed',
|
|
||||||
label: () => translate('Renamed'),
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'eventType',
|
|
||||||
value: '6',
|
|
||||||
type: filterTypes.EQUAL
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ignored',
|
|
||||||
label: () => translate('Ignored'),
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'eventType',
|
|
||||||
value: '7',
|
|
||||||
type: filterTypes.EQUAL
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
filterBuilderProps: [
|
|
||||||
{
|
|
||||||
name: 'eventType',
|
|
||||||
label: () => translate('EventType'),
|
|
||||||
type: filterBuilderTypes.EQUAL,
|
|
||||||
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'seriesIds',
|
|
||||||
label: () => translate('Series'),
|
|
||||||
type: filterBuilderTypes.EQUAL,
|
|
||||||
valueType: filterBuilderValueTypes.SERIES
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'quality',
|
|
||||||
label: () => translate('Quality'),
|
|
||||||
type: filterBuilderTypes.EQUAL,
|
|
||||||
valueType: filterBuilderValueTypes.QUALITY
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'languages',
|
|
||||||
label: () => translate('Languages'),
|
|
||||||
type: filterBuilderTypes.CONTAINS,
|
|
||||||
valueType: filterBuilderValueTypes.LANGUAGE
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
export const persistState = [
|
|
||||||
'history.pageSize',
|
|
||||||
'history.sortKey',
|
|
||||||
'history.sortDirection',
|
|
||||||
'history.selectedFilterKey',
|
|
||||||
'history.columns'
|
|
||||||
];
|
|
||||||
|
|
||||||
//
|
|
||||||
// Actions Types
|
|
||||||
|
|
||||||
export const FETCH_HISTORY = 'history/fetchHistory';
|
|
||||||
export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage';
|
|
||||||
export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage';
|
|
||||||
export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage';
|
|
||||||
export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage';
|
|
||||||
export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage';
|
|
||||||
export const SET_HISTORY_SORT = 'history/setHistorySort';
|
|
||||||
export const SET_HISTORY_FILTER = 'history/setHistoryFilter';
|
|
||||||
export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption';
|
|
||||||
export const CLEAR_HISTORY = 'history/clearHistory';
|
|
||||||
export const MARK_AS_FAILED = 'history/markAsFailed';
|
|
||||||
|
|
||||||
//
|
|
||||||
// Action Creators
|
|
||||||
|
|
||||||
export const fetchHistory = createThunk(FETCH_HISTORY);
|
|
||||||
export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE);
|
|
||||||
export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE);
|
|
||||||
export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE);
|
|
||||||
export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE);
|
|
||||||
export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE);
|
|
||||||
export const setHistorySort = createThunk(SET_HISTORY_SORT);
|
|
||||||
export const setHistoryFilter = createThunk(SET_HISTORY_FILTER);
|
|
||||||
export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION);
|
|
||||||
export const clearHistory = createAction(CLEAR_HISTORY);
|
|
||||||
export const markAsFailed = createThunk(MARK_AS_FAILED);
|
|
||||||
|
|
||||||
//
|
|
||||||
// Action Handlers
|
|
||||||
|
|
||||||
export const actionHandlers = handleThunks({
|
|
||||||
...createServerSideCollectionHandlers(
|
|
||||||
section,
|
|
||||||
'/history',
|
|
||||||
fetchHistory,
|
|
||||||
{
|
|
||||||
[serverSideCollectionHandlers.FETCH]: FETCH_HISTORY,
|
|
||||||
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE,
|
|
||||||
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE,
|
|
||||||
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE,
|
|
||||||
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE,
|
|
||||||
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE,
|
|
||||||
[serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT,
|
|
||||||
[serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER
|
|
||||||
}),
|
|
||||||
|
|
||||||
[MARK_AS_FAILED]: function(getState, payload, dispatch) {
|
|
||||||
const id = payload.id;
|
|
||||||
|
|
||||||
dispatch(updateItem({
|
|
||||||
section,
|
|
||||||
id,
|
|
||||||
isMarkingAsFailed: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
const promise = createAjaxRequest({
|
|
||||||
url: `/history/failed/${id}`,
|
|
||||||
method: 'POST',
|
|
||||||
dataType: 'json'
|
|
||||||
}).request;
|
|
||||||
|
|
||||||
promise.done(() => {
|
|
||||||
dispatch(updateItem({
|
|
||||||
section,
|
|
||||||
id,
|
|
||||||
isMarkingAsFailed: false,
|
|
||||||
markAsFailedError: null
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
promise.fail((xhr) => {
|
|
||||||
dispatch(updateItem({
|
|
||||||
section,
|
|
||||||
id,
|
|
||||||
isMarkingAsFailed: false,
|
|
||||||
markAsFailedError: xhr
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//
|
|
||||||
// Reducers
|
|
||||||
|
|
||||||
export const reducers = createHandleActions({
|
|
||||||
|
|
||||||
[SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section),
|
|
||||||
|
|
||||||
[CLEAR_HISTORY]: createClearReducer(section, {
|
|
||||||
isFetching: false,
|
|
||||||
isPopulated: false,
|
|
||||||
error: null,
|
|
||||||
items: [],
|
|
||||||
totalPages: 0,
|
|
||||||
totalRecords: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
}, defaultState, section);
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as app from './appActions';
|
import * as app from './appActions';
|
||||||
import * as blocklist from './blocklistActions';
|
|
||||||
import * as calendar from './calendarActions';
|
import * as calendar from './calendarActions';
|
||||||
import * as captcha from './captchaActions';
|
import * as captcha from './captchaActions';
|
||||||
import * as commands from './commandActions';
|
import * as commands from './commandActions';
|
||||||
@@ -8,7 +7,6 @@ import * as episodes from './episodeActions';
|
|||||||
import * as episodeFiles from './episodeFileActions';
|
import * as episodeFiles from './episodeFileActions';
|
||||||
import * as episodeHistory from './episodeHistoryActions';
|
import * as episodeHistory from './episodeHistoryActions';
|
||||||
import * as episodeSelection from './episodeSelectionActions';
|
import * as episodeSelection from './episodeSelectionActions';
|
||||||
import * as history from './historyActions';
|
|
||||||
import * as importSeries from './importSeriesActions';
|
import * as importSeries from './importSeriesActions';
|
||||||
import * as interactiveImportActions from './interactiveImportActions';
|
import * as interactiveImportActions from './interactiveImportActions';
|
||||||
import * as oAuth from './oAuthActions';
|
import * as oAuth from './oAuthActions';
|
||||||
@@ -16,7 +14,6 @@ import * as organizePreview from './organizePreviewActions';
|
|||||||
import * as parse from './parseActions';
|
import * as parse from './parseActions';
|
||||||
import * as paths from './pathActions';
|
import * as paths from './pathActions';
|
||||||
import * as providerOptions from './providerOptionActions';
|
import * as providerOptions from './providerOptionActions';
|
||||||
import * as queue from './queueActions';
|
|
||||||
import * as releases from './releaseActions';
|
import * as releases from './releaseActions';
|
||||||
import * as rootFolders from './rootFolderActions';
|
import * as rootFolders from './rootFolderActions';
|
||||||
import * as series from './seriesActions';
|
import * as series from './seriesActions';
|
||||||
@@ -29,7 +26,6 @@ import * as wanted from './wantedActions';
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
app,
|
app,
|
||||||
blocklist,
|
|
||||||
calendar,
|
calendar,
|
||||||
captcha,
|
captcha,
|
||||||
commands,
|
commands,
|
||||||
@@ -38,7 +34,6 @@ export default [
|
|||||||
episodeFiles,
|
episodeFiles,
|
||||||
episodeHistory,
|
episodeHistory,
|
||||||
episodeSelection,
|
episodeSelection,
|
||||||
history,
|
|
||||||
importSeries,
|
importSeries,
|
||||||
interactiveImportActions,
|
interactiveImportActions,
|
||||||
oAuth,
|
oAuth,
|
||||||
@@ -46,7 +41,6 @@ export default [
|
|||||||
parse,
|
parse,
|
||||||
paths,
|
paths,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
queue,
|
|
||||||
releases,
|
releases,
|
||||||
rootFolders,
|
rootFolders,
|
||||||
series,
|
series,
|
||||||
|
|||||||
@@ -1,562 +0,0 @@
|
|||||||
import _ from 'lodash';
|
|
||||||
import React from 'react';
|
|
||||||
import { createAction } from 'redux-actions';
|
|
||||||
import { batchActions } from 'redux-batched-actions';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import { filterBuilderTypes, filterBuilderValueTypes, icons, sortDirections } from 'Helpers/Props';
|
|
||||||
import { createThunk, handleThunks } from 'Store/thunks';
|
|
||||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
|
||||||
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import { set, updateItem } from './baseActions';
|
|
||||||
import createFetchHandler from './Creators/createFetchHandler';
|
|
||||||
import createHandleActions from './Creators/createHandleActions';
|
|
||||||
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
|
|
||||||
import createClearReducer from './Creators/Reducers/createClearReducer';
|
|
||||||
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
|
|
||||||
|
|
||||||
//
|
|
||||||
// Variables
|
|
||||||
|
|
||||||
export const section = 'queue';
|
|
||||||
const status = `${section}.status`;
|
|
||||||
const details = `${section}.details`;
|
|
||||||
const paged = `${section}.paged`;
|
|
||||||
|
|
||||||
//
|
|
||||||
// State
|
|
||||||
|
|
||||||
export const defaultState = {
|
|
||||||
options: {
|
|
||||||
includeUnknownSeriesItems: true
|
|
||||||
},
|
|
||||||
|
|
||||||
removalOptions: {
|
|
||||||
removalMethod: 'removeFromClient',
|
|
||||||
blocklistMethod: 'doNotBlocklist'
|
|
||||||
},
|
|
||||||
|
|
||||||
status: {
|
|
||||||
isFetching: false,
|
|
||||||
isPopulated: false,
|
|
||||||
error: null,
|
|
||||||
item: {}
|
|
||||||
},
|
|
||||||
|
|
||||||
details: {
|
|
||||||
isFetching: false,
|
|
||||||
isPopulated: false,
|
|
||||||
error: null,
|
|
||||||
items: [],
|
|
||||||
params: {}
|
|
||||||
},
|
|
||||||
|
|
||||||
paged: {
|
|
||||||
isFetching: false,
|
|
||||||
isPopulated: false,
|
|
||||||
pageSize: 20,
|
|
||||||
sortKey: 'timeleft',
|
|
||||||
sortDirection: sortDirections.ASCENDING,
|
|
||||||
error: null,
|
|
||||||
items: [],
|
|
||||||
isGrabbing: false,
|
|
||||||
isRemoving: false,
|
|
||||||
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
columnLabel: () => translate('Status'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true,
|
|
||||||
isModifiable: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'series.sortTitle',
|
|
||||||
label: () => translate('Series'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'episode',
|
|
||||||
label: () => translate('Episode'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'episodes.title',
|
|
||||||
label: () => translate('EpisodeTitle'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'episodes.airDateUtc',
|
|
||||||
label: () => translate('EpisodeAirDate'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'languages',
|
|
||||||
label: () => translate('Languages'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'quality',
|
|
||||||
label: () => translate('Quality'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'customFormats',
|
|
||||||
label: () => translate('Formats'),
|
|
||||||
isSortable: false,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'customFormatScore',
|
|
||||||
columnLabel: () => translate('CustomFormatScore'),
|
|
||||||
label: React.createElement(Icon, {
|
|
||||||
name: icons.SCORE,
|
|
||||||
title: () => translate('CustomFormatScore')
|
|
||||||
}),
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'protocol',
|
|
||||||
label: () => translate('Protocol'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'indexer',
|
|
||||||
label: () => translate('Indexer'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'downloadClient',
|
|
||||||
label: () => translate('DownloadClient'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'title',
|
|
||||||
label: () => translate('ReleaseTitle'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'size',
|
|
||||||
label: () => translate('Size'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'outputPath',
|
|
||||||
label: () => translate('OutputPath'),
|
|
||||||
isSortable: false,
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'estimatedCompletionTime',
|
|
||||||
label: () => translate('TimeLeft'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'added',
|
|
||||||
label: () => translate('Added'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'progress',
|
|
||||||
label: () => translate('Progress'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'actions',
|
|
||||||
columnLabel: () => translate('Actions'),
|
|
||||||
isVisible: true,
|
|
||||||
isModifiable: false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
selectedFilterKey: 'all',
|
|
||||||
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'all',
|
|
||||||
label: 'All',
|
|
||||||
filters: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
filterBuilderProps: [
|
|
||||||
{
|
|
||||||
name: 'seriesIds',
|
|
||||||
label: () => translate('Series'),
|
|
||||||
type: filterBuilderTypes.EQUAL,
|
|
||||||
valueType: filterBuilderValueTypes.SERIES
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'quality',
|
|
||||||
label: () => translate('Quality'),
|
|
||||||
type: filterBuilderTypes.EQUAL,
|
|
||||||
valueType: filterBuilderValueTypes.QUALITY
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'languages',
|
|
||||||
label: () => translate('Languages'),
|
|
||||||
type: filterBuilderTypes.CONTAINS,
|
|
||||||
valueType: filterBuilderValueTypes.LANGUAGE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'protocol',
|
|
||||||
label: () => translate('Protocol'),
|
|
||||||
type: filterBuilderTypes.EQUAL,
|
|
||||||
valueType: filterBuilderValueTypes.PROTOCOL
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
label: () => translate('Status'),
|
|
||||||
type: filterBuilderTypes.EQUAL,
|
|
||||||
valueType: filterBuilderValueTypes.QUEUE_STATUS
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const persistState = [
|
|
||||||
'queue.options',
|
|
||||||
'queue.removalOptions',
|
|
||||||
'queue.paged.pageSize',
|
|
||||||
'queue.paged.sortKey',
|
|
||||||
'queue.paged.sortDirection',
|
|
||||||
'queue.paged.columns',
|
|
||||||
'queue.paged.selectedFilterKey'
|
|
||||||
];
|
|
||||||
|
|
||||||
//
|
|
||||||
// Helpers
|
|
||||||
|
|
||||||
function fetchDataAugmenter(getState, payload, data) {
|
|
||||||
data.includeUnknownSeriesItems = getState().queue.options.includeUnknownSeriesItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Actions Types
|
|
||||||
|
|
||||||
export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus';
|
|
||||||
|
|
||||||
export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails';
|
|
||||||
export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails';
|
|
||||||
|
|
||||||
export const FETCH_QUEUE = 'queue/fetchQueue';
|
|
||||||
export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage';
|
|
||||||
export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage';
|
|
||||||
export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage';
|
|
||||||
export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage';
|
|
||||||
export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage';
|
|
||||||
export const SET_QUEUE_SORT = 'queue/setQueueSort';
|
|
||||||
export const SET_QUEUE_FILTER = 'queue/setQueueFilter';
|
|
||||||
export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption';
|
|
||||||
export const SET_QUEUE_OPTION = 'queue/setQueueOption';
|
|
||||||
export const SET_QUEUE_REMOVAL_OPTION = 'queue/setQueueRemoveOption';
|
|
||||||
export const CLEAR_QUEUE = 'queue/clearQueue';
|
|
||||||
|
|
||||||
export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem';
|
|
||||||
export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems';
|
|
||||||
export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem';
|
|
||||||
export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems';
|
|
||||||
|
|
||||||
//
|
|
||||||
// Action Creators
|
|
||||||
|
|
||||||
export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS);
|
|
||||||
|
|
||||||
export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS);
|
|
||||||
export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS);
|
|
||||||
|
|
||||||
export const fetchQueue = createThunk(FETCH_QUEUE);
|
|
||||||
export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE);
|
|
||||||
export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE);
|
|
||||||
export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE);
|
|
||||||
export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE);
|
|
||||||
export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE);
|
|
||||||
export const setQueueSort = createThunk(SET_QUEUE_SORT);
|
|
||||||
export const setQueueFilter = createThunk(SET_QUEUE_FILTER);
|
|
||||||
export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION);
|
|
||||||
export const setQueueOption = createAction(SET_QUEUE_OPTION);
|
|
||||||
export const setQueueRemovalOption = createAction(SET_QUEUE_REMOVAL_OPTION);
|
|
||||||
export const clearQueue = createAction(CLEAR_QUEUE);
|
|
||||||
|
|
||||||
export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM);
|
|
||||||
export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS);
|
|
||||||
export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM);
|
|
||||||
export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS);
|
|
||||||
|
|
||||||
//
|
|
||||||
// Helpers
|
|
||||||
|
|
||||||
const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details');
|
|
||||||
|
|
||||||
//
|
|
||||||
// Action Handlers
|
|
||||||
|
|
||||||
export const actionHandlers = handleThunks({
|
|
||||||
|
|
||||||
[FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'),
|
|
||||||
|
|
||||||
[FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) {
|
|
||||||
let params = payload;
|
|
||||||
|
|
||||||
// If the payload params are empty try to get params from state.
|
|
||||||
|
|
||||||
if (params && !_.isEmpty(params)) {
|
|
||||||
dispatch(set({ section: details, params }));
|
|
||||||
} else {
|
|
||||||
params = getState().queue.details.params;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure there are params before trying to fetch the queue
|
|
||||||
// so we don't make a bad request to the server.
|
|
||||||
|
|
||||||
if (params && !_.isEmpty(params)) {
|
|
||||||
fetchQueueDetailsHelper(getState, params, dispatch);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
...createServerSideCollectionHandlers(
|
|
||||||
paged,
|
|
||||||
'/queue',
|
|
||||||
fetchQueue,
|
|
||||||
{
|
|
||||||
[serverSideCollectionHandlers.FETCH]: FETCH_QUEUE,
|
|
||||||
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE,
|
|
||||||
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE,
|
|
||||||
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE,
|
|
||||||
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE,
|
|
||||||
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE,
|
|
||||||
[serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT,
|
|
||||||
[serverSideCollectionHandlers.FILTER]: SET_QUEUE_FILTER
|
|
||||||
},
|
|
||||||
fetchDataAugmenter
|
|
||||||
),
|
|
||||||
|
|
||||||
[GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) {
|
|
||||||
const id = payload.id;
|
|
||||||
|
|
||||||
dispatch(updateItem({ section: paged, id, isGrabbing: true }));
|
|
||||||
|
|
||||||
const promise = createAjaxRequest({
|
|
||||||
url: `/queue/grab/${id}`,
|
|
||||||
method: 'POST'
|
|
||||||
}).request;
|
|
||||||
|
|
||||||
promise.done((data) => {
|
|
||||||
dispatch(batchActions([
|
|
||||||
fetchQueue(),
|
|
||||||
|
|
||||||
set({
|
|
||||||
section: paged,
|
|
||||||
isGrabbing: false,
|
|
||||||
grabError: null
|
|
||||||
})
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
|
|
||||||
promise.fail((xhr) => {
|
|
||||||
dispatch(updateItem({
|
|
||||||
section: paged,
|
|
||||||
id,
|
|
||||||
isGrabbing: false,
|
|
||||||
grabError: xhr
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
[GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) {
|
|
||||||
const ids = payload.ids;
|
|
||||||
|
|
||||||
dispatch(batchActions([
|
|
||||||
...ids.map((id) => {
|
|
||||||
return updateItem({
|
|
||||||
section: paged,
|
|
||||||
id,
|
|
||||||
isGrabbing: true
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
set({
|
|
||||||
section: paged,
|
|
||||||
isGrabbing: true
|
|
||||||
})
|
|
||||||
]));
|
|
||||||
|
|
||||||
const promise = createAjaxRequest({
|
|
||||||
url: '/queue/grab/bulk',
|
|
||||||
method: 'POST',
|
|
||||||
dataType: 'json',
|
|
||||||
data: JSON.stringify(payload)
|
|
||||||
}).request;
|
|
||||||
|
|
||||||
promise.done((data) => {
|
|
||||||
dispatch(fetchQueue());
|
|
||||||
|
|
||||||
dispatch(batchActions([
|
|
||||||
...ids.map((id) => {
|
|
||||||
return updateItem({
|
|
||||||
section: paged,
|
|
||||||
id,
|
|
||||||
isGrabbing: false,
|
|
||||||
grabError: null
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
set({
|
|
||||||
section: paged,
|
|
||||||
isGrabbing: false,
|
|
||||||
grabError: null
|
|
||||||
})
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
|
|
||||||
promise.fail((xhr) => {
|
|
||||||
dispatch(batchActions([
|
|
||||||
...ids.map((id) => {
|
|
||||||
return updateItem({
|
|
||||||
section: paged,
|
|
||||||
id,
|
|
||||||
isGrabbing: false,
|
|
||||||
grabError: null
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
set({ section: paged, isGrabbing: false })
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
[REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
remove,
|
|
||||||
blocklist,
|
|
||||||
skipRedownload,
|
|
||||||
changeCategory
|
|
||||||
} = payload;
|
|
||||||
|
|
||||||
dispatch(updateItem({ section: paged, id, isRemoving: true }));
|
|
||||||
|
|
||||||
const promise = createAjaxRequest({
|
|
||||||
url: `/queue/${id}?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
|
|
||||||
method: 'DELETE'
|
|
||||||
}).request;
|
|
||||||
|
|
||||||
promise.done((data) => {
|
|
||||||
dispatch(fetchQueue());
|
|
||||||
});
|
|
||||||
|
|
||||||
promise.fail((xhr) => {
|
|
||||||
dispatch(updateItem({ section: paged, id, isRemoving: false }));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
[REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) {
|
|
||||||
const {
|
|
||||||
ids,
|
|
||||||
remove,
|
|
||||||
blocklist,
|
|
||||||
skipRedownload,
|
|
||||||
changeCategory
|
|
||||||
} = payload;
|
|
||||||
|
|
||||||
dispatch(batchActions([
|
|
||||||
...ids.map((id) => {
|
|
||||||
return updateItem({
|
|
||||||
section: paged,
|
|
||||||
id,
|
|
||||||
isRemoving: true
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
set({ section: paged, isRemoving: true })
|
|
||||||
]));
|
|
||||||
|
|
||||||
const promise = createAjaxRequest({
|
|
||||||
url: `/queue/bulk?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
|
|
||||||
method: 'DELETE',
|
|
||||||
dataType: 'json',
|
|
||||||
contentType: 'application/json',
|
|
||||||
data: JSON.stringify({ ids })
|
|
||||||
}).request;
|
|
||||||
|
|
||||||
promise.done((data) => {
|
|
||||||
// Don't use batchActions with thunks
|
|
||||||
dispatch(fetchQueue());
|
|
||||||
|
|
||||||
dispatch(set({ section: paged, isRemoving: false }));
|
|
||||||
});
|
|
||||||
|
|
||||||
promise.fail((xhr) => {
|
|
||||||
dispatch(batchActions([
|
|
||||||
...ids.map((id) => {
|
|
||||||
return updateItem({
|
|
||||||
section: paged,
|
|
||||||
id,
|
|
||||||
isRemoving: false
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
set({ section: paged, isRemoving: false })
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//
|
|
||||||
// Reducers
|
|
||||||
|
|
||||||
export const reducers = createHandleActions({
|
|
||||||
|
|
||||||
[CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details),
|
|
||||||
|
|
||||||
[SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged),
|
|
||||||
|
|
||||||
[SET_QUEUE_OPTION]: function(state, { payload }) {
|
|
||||||
const queueOptions = state.options;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
options: {
|
|
||||||
...queueOptions,
|
|
||||||
...payload
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
[SET_QUEUE_REMOVAL_OPTION]: function(state, { payload }) {
|
|
||||||
const queueRemovalOptions = state.removalOptions;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
removalOptions: {
|
|
||||||
...queueRemovalOptions,
|
|
||||||
...payload
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
[CLEAR_QUEUE]: createClearReducer(paged, {
|
|
||||||
isFetching: false,
|
|
||||||
isPopulated: false,
|
|
||||||
error: null,
|
|
||||||
items: [],
|
|
||||||
totalPages: 0,
|
|
||||||
totalRecords: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
}, defaultState, section);
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user